Files
ledgrab/server/tests/test_main_entrypoint.py
T
alexei.dolgolyov a5effba553 feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers
Backend
- snapshot: GET /api/v1/snapshot aggregates targets, devices, sources,
  presets and system into one payload for the HA coordinator, collapsing
  the prior ~2N+M request fan-out; per-section ?include= gating.
- graph: GET /api/v1/graph{,/schema,/dependents} backed by a pure,
  unit-tested graph_schema engine — one authoritative connectable-field
  registry so the editor no longer hard-codes topology in two places.
- devices: thread mqtt_source_id through DeviceCreate/Update/Response and
  the routes for multi-broker MQTT; shared validate_mqtt_source_exists
  (_mqtt_validation.py) reused by device + output-target routes; stop
  update_device masking intentional 4xx as 500.
- shutdown: bound uvicorn graceful-shutdown via GRACEFUL_SHUTDOWN_TIMEOUT
  (shared by __main__, android_entry, demo) so a lingering events WebSocket
  can't strand LED targets or block process exit.
- access log: structured _access_log middleware attributing each request to
  its authenticated token label (never the secret); uvicorn access_log off.

Frontend
- graph editor: generic schema-driven port/edge rendering, layout and
  connection handling; service-worker refresh.
- device modals: MQTT broker EntitySelect for device_type=mqtt in add-device
  and settings, wired into load/save/validate/dirty-check/clone.
- i18n: en/ru/zh keys.

Tests: graph routes + schema, snapshot routes, access log, mqtt_source_id
device regressions, bounded-shutdown entrypoint. 1614 passed.
2026-05-28 22:51:04 +03:00

100 lines
3.8 KiB
Python

"""Tests for the ``__main__`` entry-point helpers.
These cover the bits that aren't exercised by the FastAPI test client —
the signal-handler install path and the shutdown-state plumbing — so a
regression in the launcher can't silently break the user's
"stop targets on PC shutdown" guarantee.
"""
from __future__ import annotations
import signal
import threading
from types import SimpleNamespace
from ledgrab.__main__ import (
_build_server,
_install_signal_handlers,
_request_shutdown,
)
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
def test_request_shutdown_sets_should_exit() -> None:
server = SimpleNamespace(should_exit=False)
_request_shutdown(server)
assert server.should_exit is True
def test_build_server_bounds_graceful_shutdown() -> None:
"""uvicorn defaults ``timeout_graceful_shutdown`` to ``None`` (wait
forever). A lingering events WebSocket then blocks ``Server.shutdown()``
from ever reaching the lifespan shutdown, so LED targets are never
stopped and the process can't exit. The bound is what guarantees the
lifespan shutdown runs — assert we never regress to the unbounded default.
"""
fake_config = SimpleNamespace(
server=SimpleNamespace(host="127.0.0.1", port=8080, log_level="INFO")
)
server = _build_server(fake_config) # type: ignore[arg-type]
assert server.config.timeout_graceful_shutdown == GRACEFUL_SHUTDOWN_TIMEOUT
assert server.config.timeout_graceful_shutdown is not None
assert server.config.timeout_graceful_shutdown > 0
def test_graceful_shutdown_timeout_fits_os_budget() -> None:
"""The graceful wait runs BEFORE the lifespan's own ~16 s shutdown budget,
and OS shutdown gives the whole process only ~20 s before a force-kill.
Keep the bound small so target restore + DB checkpoint still fit.
"""
assert isinstance(GRACEFUL_SHUTDOWN_TIMEOUT, int)
assert 0 < GRACEFUL_SHUTDOWN_TIMEOUT <= 5
def test_install_signal_handlers_installs_for_known_signals() -> None:
"""Tray path runs uvicorn on a background thread, so our handlers must
actually survive — verify each catchable signal is replaced.
"""
server = SimpleNamespace(should_exit=False)
previous = {
name: signal.getsignal(getattr(signal, name))
for name in ("SIGINT",)
if hasattr(signal, name)
}
try:
_install_signal_handlers(server)
for name in ("SIGINT", "SIGTERM", "SIGBREAK"):
sig = getattr(signal, name, None)
if sig is None:
continue
current = signal.getsignal(sig)
# The handler is our local closure — its qualname starts with the function it's defined in.
assert callable(current), f"{name} handler should be installed"
assert getattr(current, "__qualname__", "").startswith(
"_install_signal_handlers"
), f"{name} should be replaced by our handler, got {current!r}"
finally:
# Restore original handlers so the rest of the test suite isn't poisoned.
for name, handler in previous.items():
signal.signal(getattr(signal, name), handler)
def test_shutdown_state_is_shared_threading_event() -> None:
"""``__main__`` and ``main`` must share the same Event instance — if a
fresh one is constructed on either side, WM_ENDSESSION waits forever.
"""
from ledgrab.shutdown_state import shutdown_complete as state_event
assert isinstance(state_event, threading.Event)
# If main.py is importable, confirm it re-exports the same object.
try:
from ledgrab.main import shutdown_complete as main_event
except Exception:
return # main.py needs full app state — fine to skip on a bare test run.
assert main_event is state_event, "main.py must re-export the same Event, not create a new one"