Files
homelable/mcp/tests/test_faceplates_sync.py
T
Pouzor 4c8831d5df feat(mcp): expose the rack canvas and the inventory write routes
The MCP server covered the logical canvas only. A client could create a
design whose design_type is `rack` and then had no way to put a rack, a
mount or a cable in it, and it could triage the Device Inventory but
never create, edit or delete an entry — though every route already
existed. 24 tools become 47.

Racks (mcp/app/racks.py), 13 tools: list_racks, get_rack,
list_rack_inventory, list_faceplates, create_rack, update_rack,
delete_rack, mount_device, mount_accessory, unmount_device, move_device,
set_device_faceplate, patch_cable, unpatch_cable.

`POST /api/v1/racks/save` is full-state upsert-and-prune, so every write
is load, mutate, save it all back. Two traps are encoded once, in the
helpers: the response rows carry `design_id` and the save schemas do not
(echoing one back verbatim is a 422), and the viewport has to be echoed
or the save resets the user's pan and zoom. The plate, its colour and its
ports need no special handling — /racks/save writes those through to the
inventory row itself.

Two frontend files are copied rather than imported, since the MCP server
is a separate service:

  - mcp/app/faceplates.py — the 21-plate catalog from
    frontend/src/rack/faceplates.ts, data only. `bank()` reproduces the
    port arithmetic exactly, so a plate seeded here lands its ports where
    the renderer draws them.
  - mcp/app/rack_layout.py — canPlace / findSlot / freeUnits from
    frontend/src/rack/layout.ts, to *choose* a slot. The backend still
    polices the one it is sent.

Both are guarded against drift: test_faceplates_sync.py parses
faceplates.ts and compares every plate down to each port's coordinates,
the way test_node_types_sync.py guards NODE_TYPES, and
test_layout_parity.py runs the frontend's own layout cases against the
Python port.

Devices (mcp/app/devices.py), 10 tools: create_device, update_device,
delete_device, bulk_approve_devices, bulk_hide_devices,
bulk_restore_devices, rescan_device, list_proxmox_children,
get_scan_config, update_scan_config.

Verified against a running backend: the rack flow end to end (create,
mount, patch, move, swap a plate, shrink, unmount) plus every refusal
path, and the device tools including a byte-identical scan-config
write-back.

ha-relevant: no
2026-09-04 15:10:05 +02:00

157 lines
6.2 KiB
Python

"""The Python faceplate catalog is a hand copy of the frontend one.
A plate added, resized or reported here drifts silently otherwise: a mount would
be seeded with the wrong footprint or the wrong ports, and the renderer would
draw something else than the tool said it mounted. This parses faceplates.ts and
compares, the way test_node_types_sync.py does for NODE_TYPES.
"""
import re
from pathlib import Path
import pytest
from app.faceplates import (
DEVICE_TYPE_BY_FACEPLATE,
FACEPLATE_BY_DEVICE_TYPE,
FACEPLATES,
NAS_BAND,
bank,
)
FRONTEND_FACEPLATES = (
Path(__file__).resolve().parents[2] / "frontend" / "src" / "rack" / "faceplates.ts"
)
# Constants faceplates.ts writes its dimensions in terms of.
_CONSTANTS = {"RACK_COLUMNS": 12, "NAS_U": 5}
def _number(expression: str) -> int:
"""`RACK_COLUMNS / 6` and friends, resolved to the int the TS produces.
A literal, a named constant, or a constant divided by a literal — the only
three shapes the catalog uses. Parsed rather than evaluated.
"""
resolved = expression.strip().rstrip(",")
match = re.fullmatch(r"(\w+)(?:\s*/\s*(\d+))?", resolved)
assert match, f"Unparsable dimension {expression!r} in faceplates.ts"
term, divisor = match.group(1), match.group(2)
value = _CONSTANTS[term] if term in _CONSTANTS else int(term)
return value // int(divisor) if divisor else value
def _entries() -> list[str]:
source = FRONTEND_FACEPLATES.read_text()
start = source.index("export const FACEPLATES")
end = source.index("\n]\n", start)
body = source[start:end]
# Every catalog entry is an object opened at two-space indentation.
entries = body.split("\n {\n")[1:]
assert entries, (
"Could not split the FACEPLATES array in frontend/src/rack/faceplates.ts — "
"update this parser if the file's shape changed."
)
return entries
def _bank_calls(ports_source: str) -> list[dict]:
"""Every `bank({...})` in a plate's `ports:` value, as Python kwargs."""
calls = []
for raw in re.findall(r"bank\(\{([^}]*)\}\)", ports_source):
kwargs: dict = {}
for key, quoted, number in re.findall(r"(\w+):\s*(?:'([^']*)'|([\d.]+))", raw):
kwargs[key] = quoted if number == "" else float(number)
calls.append({
"type": kwargs["type"],
"count": int(kwargs["count"]),
"x": kwargs["x"],
"w": kwargs["w"],
"per_row": int(kwargs["perRow"]) if "perRow" in kwargs else None,
"prefix": kwargs.get("prefix", "P"),
"start": int(kwargs.get("start", 1)),
})
return calls
def _parse(entry: str) -> dict:
plate = {
"id": re.search(r"id: '([^']+)'", entry).group(1),
"label": re.search(r"label: '([^']+)'", entry).group(1),
"kind": re.search(r"kind: '([^']+)'", entry).group(1),
"group": re.search(r"group: '([^']+)'", entry).group(1),
"u_height": _number(re.search(r"uHeight: ([^,\n]+)", entry).group(1)),
"col_span": _number(re.search(r"colSpan: ([^,\n]+)", entry).group(1)),
}
# `ports:` is the last key of every entry, so everything after it is the
# port declaration — and nothing from `elements:` (which has `count:` of its
# own, for outlets) can leak in.
ports_source = entry[entry.index("ports:"):]
ports: list[dict] = []
for call in _bank_calls(ports_source):
ports.extend(bank(**call))
if "nasPorts(" in ports_source:
ports = [{**p, "y": NAS_BAND} for p in ports]
plate["ports"] = ports
return plate
def test_faceplates_in_sync_with_frontend():
frontend = [_parse(entry) for entry in _entries()]
frontend_ids = [p["id"] for p in frontend]
catalog_ids = [p["id"] for p in FACEPLATES]
assert catalog_ids == frontend_ids, (
"mcp/app/faceplates.py FACEPLATES is out of sync with "
"frontend/src/rack/faceplates.ts.\n"
f"Missing: {[i for i in frontend_ids if i not in catalog_ids]}\n"
f"Extra: {[i for i in catalog_ids if i not in frontend_ids]}\n"
"(order matters — it is the order the picker shows)"
)
by_id = {p["id"]: p for p in FACEPLATES}
for expected in frontend:
plate = by_id[expected["id"]]
for field in ("label", "kind", "group", "u_height", "col_span"):
assert plate[field] == expected[field], (
f"Faceplate '{expected['id']}' {field}: catalog has {plate[field]!r}, "
f"faceplates.ts has {expected[field]!r}"
)
assert len(plate["ports"]) == len(expected["ports"]), (
f"Faceplate '{expected['id']}' has {len(plate['ports'])} ports, "
f"faceplates.ts declares {len(expected['ports'])}"
)
for port, want in zip(plate["ports"], expected["ports"]):
assert port["label"] == want["label"] and port["type"] == want["type"], (
f"Faceplate '{expected['id']}' port {port['label']}/{port['type']} "
f"does not match {want['label']}/{want['type']}"
)
assert port["x"] == pytest.approx(want["x"]), f"{expected['id']} {port['label']} x"
assert port["y"] == pytest.approx(want["y"]), f"{expected['id']} {port['label']} y"
def _ts_record(name: str) -> dict[str, str]:
source = FRONTEND_FACEPLATES.read_text()
match = re.search(rf"const {name}: Record<string, string> = \{{(.*?)\n\}}", source, re.S)
assert match, f"Could not locate {name} in frontend/src/rack/faceplates.ts"
return dict(re.findall(r"'?([\w-]+)'?:\s*'([^']+)'", match.group(1)))
def test_faceplate_suggestion_maps_in_sync():
assert FACEPLATE_BY_DEVICE_TYPE == _ts_record("FACEPLATE_BY_DEVICE_TYPE"), (
"mcp/app/faceplates.py FACEPLATE_BY_DEVICE_TYPE is out of sync with faceplates.ts"
)
assert DEVICE_TYPE_BY_FACEPLATE == _ts_record("DEVICE_TYPE_BY_FACEPLATE"), (
"mcp/app/faceplates.py DEVICE_TYPE_BY_FACEPLATE is out of sync with faceplates.ts"
)
def test_every_faceplate_is_reachable_by_id():
"""A plate the catalog lists but `get_faceplate` cannot return would make
every tool that names it fail."""
from app.faceplates import get_faceplate
for plate in FACEPLATES:
assert get_faceplate(plate["id"]) is plate
assert get_faceplate("no-such-plate") is None