Compare commits

...
Author SHA1 Message Date
Claude bdf4d25c47 fix: keep callback faults out of the frame-decode fallback
Review follow-up on the previous two commits.

Widening the control-message except tuple put the callback dispatch
inside it, so an _on_new_cycle() that raised ValueError, TypeError or
AttributeError sent a perfectly good control packet to the legacy PNG
decoder -- which reported it as an image decode error and buried the
real fault. Split the two: whether the payload parses as JSON decides
frame vs control message, a second guard covers reading the fields of an
attacker-shaped body, and the callback fires outside both. It still
cannot kill the receive thread; the loop's own handler catches it, and
now says what actually went wrong.

The logo download's temp file was a fixed "<name>.part". Two plugins
asking for the same logo at once would interleave writes into it,
publish the mixture, or delete each other's partial. mkstemp gives each
download its own name in the same directory, so os.replace stays atomic.
Its descriptor is adopted by fdopen before the request runs, since a
request that raises before the write would otherwise leak the fd --
quietly, because load_logo_with_download swallows that.

Two test fixes: the oversized-frame test replaced PIL.Image.open
process-wide, the same hazard the clock helper documents, and Ruff B007
on an unused loop variable.

Full suite: 3355 passed, coverage 54%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-14 13:13:32 +00:00
Claude a653368250 test(sync): probe broadcast by sending, not by listening
The broadcast check added in the previous commit bound INADDR_ANY to
receive its own probe datagram, and the free-port probe did the same to
pick a port. CodeQL flagged both, correctly: a test suite has no reason
to open a socket the whole network can reach.

Sending is enough for what the probe is actually for. An environment
that refuses broadcast raises on sendto, which is the case that occurs
in sandboxes and is the one worth skipping over; confirming delivery
would have required the listening socket. A network that accepts the
send and silently drops it still reaches the assertion, exactly as it
did before either commit. The port probe binds loopback -- it only needs
a number, and the manager's own bind is the one that has to succeed, with
the retry loop already covering a port taken elsewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-14 13:04:32 +00:00
Claude e87797e997 fix: bound the logo download, and stop malformed input reading as a fault
Review findings on the coverage branch.

The download size cap I added checked len(response.content), which has
already buffered the whole body -- it stopped the bytes reaching disk but
not memory, which was the point. A server that omits Content-Length and
never stops sending would still exhaust the process. Stream it instead,
counting as it arrives, into a sibling .part file that is replaced over
the target only once it decodes. A transfer that dies midway now leaves
nothing behind rather than a truncated logo for load_logo() to cache.

The follower's control-message handler caught three exception types, but
two reachable UDP payloads raise others: a bare JSON scalar makes
msg.get() raise AttributeError, and an "sx" carrying a non-numeric x
raises ValueError or TypeError from float(). Those escaped to the outer
handler, skipping the legacy-PNG fallback and -- since this branch added
a backoff there -- charging one malformed packet a 0.1s stall on the
receive path. The legacy-PNG path also decoded without the dimension cap
its TCP counterpart applies, so a crafted 65KB frame could force a large
allocation on the render thread; both paths now share one constant.

Three repo_url handlers called .strip() on client input without checking
it was a string, so {"repo_url": 12345} answered 500. The credentials
upload parsed the same file twice, the second time inside a bare except
that a preceding parse had already made unreachable. And both raw-config
handlers kept a json.JSONDecodeError arm that get_json(silent=True) had
turned into dead code, collapsing "sent something unparseable" into "sent
nothing" -- they now say which.

Two of the new tests were not testing what they claimed. The pruning
round-trip wrote ten backups inside one second, so all ten landed on the
same int(time.time()) filename and overwrote each other; it never reached
the limit it asserted. And the sync clock helper patched attributes on the
stdlib time module, freezing time process-wide for every daemon thread
earlier tests had left running.

Full suite: 3352 passed, coverage 54%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-14 13:01:29 +00:00
Claude 461de4ce90 test(sync): probe for a free port on loopback, not every interface
CodeQL flagged the ephemeral-port probe in the handshake test for
binding to all interfaces. The probe only needs a free port number, so
loopback is both sufficient and correct — a test should not open a port
to the network to discover one.

The manager under test still binds to all interfaces, which is
deliberate and already marked nosec: a follower has to receive the
leader's UDP broadcast.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 14:18:52 +00:00
Claude f18b61aa8a ci: raise coverage floor to 52%
Measured 54.45% after the Tier 1 and Tier 2 suites, up from 50%. Keeping
the same two points of headroom the 45 -> 48 ratchet used.

The modules this branch set out to cover: sync_manager 0 -> 97%,
logo_helper 0 -> 98%, errors and error_handler 0 -> 100%, validators
0 -> 97%. api_v3 moved less in percentage terms because it is 4,341
statements, but the endpoints covered are the destructive and
credential-handling ones.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 14:12:06 +00:00
Claude bbe2a63127 test(api): cover backup restore and path containment, and fix restore scope
Restore is the most destructive thing the web interface can do — it
overwrites config, secrets, WiFi settings and fonts, then reinstalls
plugins — and neither it nor the file routes beside it had tests.

A malformed `options` field fell back to {}. Every RestoreOptions flag
defaults to True, so a caller who asked for a narrow restore and
mis-serialized the request got a full one instead, secrets included, and
was told it succeeded. Valid JSON that is not an object was worse:
`"null"` or `"[1,2]"` reached .get() on a non-dict and raised, so the
request died as a generic 500. Both are now refused with a 400 that says
what was wrong, and restore_backup is never reached.

The other file routes take a filename straight out of the URL and turn it
into a path — one to read, one to unlink. _safe_backup_path is the only
thing keeping those inside the export directory, and it was untested. No
bypass was found; the thirteen traversal shapes are pinned so a later
loosening of that pattern has to argue with something. The delete route's
by-name enumeration is covered too, including that a directory sharing a
backup's name is not removed.

84 tests. Two behaviours are pinned as intentional: a failed plugin
reinstall turns the whole restore into an error even though file
restoration succeeded, and omitting `options` entirely still means
restore everything — that is the documented default, and it is only the
mis-serialized case that was wrong.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 14:01:52 +00:00
Claude 320ee797d7 test(api): cover the raw config write endpoints
/config/raw/main and /config/raw/secrets write whatever JSON they are
given straight to config.json and config_secrets.json, bypassing the
secret-separation path the rest of the config surface goes through. Given
how carefully that surface keeps secrets out of config.json, the pair
that skips it was worth pinning precisely. Backed by a real
ConfigManager over tmp_path, so the assertions are against files on disk.

20 tests covering both routes: what lands in which file, that a raw
secrets write never touches config.json and vice versa, the GitHub token
reload, the uninitialized-manager and empty-body branches, and the
ConfigError path that carries config_path through to the response.

The bypass itself is pinned as intentional rather than changed — these
back the raw JSON editor, so writing the body verbatim is the feature.
The test says so explicitly, because the failure mode is someone later
routing plugin config through here as a convenience and silently losing
secret separation.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 13:59:09 +00:00
Claude f57f864ae9 test(api): cover the install endpoints, and make 14 dead guards reachable
/plugins/install and /plugins/install-from-url were tested only at the
PluginStoreManager layer, so the route logic — the queue-versus-direct
branch, schema invalidation, discovery, state and history recording — was
unexercised.

Covering them surfaced the wider form of the body-parsing bug fixed for
the `or {}` handlers in the previous commit. Fourteen handlers read
`data = request.get_json()` and immediately guard with `if not data:
return 400, 'No data provided'`. That guard cannot run: get_json()
without silent=True raises UnsupportedMediaType for a request with no
JSON body, so the catch-all answered 500 "an error occurred; see logs
for details" where the handler plainly meant to answer 400 and say
which field was missing. Every one of these endpoints told a caller who
simply forgot the body to go read the server logs.

All fourteen now use silent=True, so the guard each author already wrote
is the one that runs. This covers /config/raw/main and /config/raw/secrets
among them, whose own bodyless case had the same shape.

The two remaining bare reads are left alone: neither declares what a
missing body should do, so there is no stated intent to honour.

31 install tests plus 17 body tests. The install pair is checked against
each other rather than only individually — the same install logic is
written twice, once in the queue callback and once in the fallback, so
the tests assert both produce identical schema, discovery, state and
history effects. They agree today; the one difference is the success
message wording, which is characterized rather than changed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 13:58:09 +00:00
Claude 7cb42848fd test(api): cover the credentials upload, and stop it hoarding secrets
The endpoint that receives the user's Google OAuth credentials file had
no tests. Two bugs surfaced.

The OAuth-shape check ran inside `except Exception: pass`. A JSON
document that parses but is not an object — a bare 42, true, null, a
list — makes `'installed' not in creds_data` raise TypeError, which the
bare except swallowed, and the file was then written out as
credentials.json regardless. The check now decides the outcome instead
of being advisory, so anything not credentials-shaped is refused up
front rather than failing later inside the calendar plugin.

Every overwrite copies the old file to credentials.json.backup.<ts> and
nothing removed them, so a user who re-uploaded ten times had ten
complete sets of OAuth client credentials sitting in the plugin
directory, indefinitely. Keep the newest five. Pruning is housekeeping,
so a backup that cannot be removed logs and leaves the upload alone.

27 tests: size and extension limits, malformed JSON, the shape check,
0600 permissions on the written file, backup-on-overwrite, and pruning
including the repeated-upload case that stays bounded.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 13:54:09 +00:00
Claude 799733fb1d test(api): cover the music auth endpoints, and always clean up the wrapper
The Spotify step-2 handler writes a Python wrapper script to a temp file
with the user's redirect URL embedded in its source, then executes it.
That is the most dangerous shape in the blueprint and had no tests.

The wrapper was deleted in the success/failure branch and again in the
TimeoutExpired handler. Any other failure from subprocess.run — no
interpreter, a fork failure, an interrupted call — reached neither, and
left a world-readable temp file containing the user's redirect URL on
disk. Cleanup moves to a finally block, which is what "delete this
whatever happens" should have been from the start.

The injection tests are the point of this file. Eight adversarial
redirect URLs (embedded quotes, backslashes, newlines, triple quotes, a
full `"; import os; os.system("id"); "`) are each pushed through the
endpoint and the generated wrapper is parsed with ast: it must still be
valid Python, the URL must still be a single string literal bound to
redirect_url, and no os.system call may appear anywhere in the tree.
json.dumps holds up, but nothing was checking that it does.

40 tests. Also pins that the two endpoints are not symmetrical despite
the matching names — only Spotify has a two-step flow and a wrapper; YTM
runs its script directly — so a later change does not "restore" a parity
that was never there.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 13:52:17 +00:00
Claude 13dad4570a test(api): cover wifi and registry endpoints, and fix bodyless POSTs
The /wifi/* routes drive the host's real networking and the registry
routes reach GitHub, and neither had endpoint-level tests. Covering them
surfaced a bug affecting six endpoints.

Six handlers read their body as `request.get_json() or {}`. The `or {}`
says every field is optional and a missing body should fall back to
defaults — but get_json() without silent=True raises UnsupportedMediaType
when there is no JSON Content-Type, and it raises before `or {}` is ever
evaluated. Each handler's catch-all then reported that as a 500. So
POSTing with no body — what curl sends by default, and what a fetch()
without options sends — failed on /plugins/store/refresh,
/display/on-demand/start, /plugins/config/reset,
/plugins/of-the-day/json/delete, /plugins/{id}/limits and
/plugins/authenticate/spotify. The shipped UI always sends a JSON object,
which is why this stayed hidden.

All six now use silent=True. test_api_v3_optional_body.py covers the
affected endpoints and adds a source check, since the combination of
`or <default>` with a non-silent read is self-contradictory wherever it
appears and is easier to catch by inspection than by exercising each
endpoint by hand.

Also adds test/_api_v3_test_helpers.py: the blueprint holds its managers
on a module-level singleton rather than in Flask app state, so a test
that mocks them leaks into every later test unless the originals are
restored. The existing _make_client() does this for unittest classes;
this is the pytest-fixture equivalent, for the five suites still to come.

69 endpoint tests: connect/disconnect/AP/radio including the string-aware
boolean coercion these endpoints deliberately use, the radio's
lockout-refusal path, registry refresh and fetch-from-URL, and a guard
that WiFiManager is never constructed for real.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 13:50:21 +00:00
Claude 54d1e314e4 test(web): cover the input validators, and close three holes in them
validators.py had tests for dedup_unique_arrays only; the other eight
functions were untested. Three bugs surfaced.

validate_image_url checked for '..' only inside its relative-path
branch, so http://host/../secret passed validation while /../secret was
rejected — the traversal check now runs before the branch split, which
is where a safety check on the whole URL belongs.

validate_file_upload lowercased the uploaded filename's extension but
compared it against the caller's list verbatim, so allowed_extensions of
['.TTF'] rejected every valid .ttf file. Both sides are lowercased now.
The one in-tree caller passes lowercase already, so this only widens what
future callers can hand it.

validate_numeric_range accepted True and False, because bool subclasses
int; a boolean then compared as 1 or 0 against the range and validated
cleanly. Excluded explicitly, matching how base_plugin.py already handles
the same trap for display_duration.

84 tests. Two behaviours are pinned rather than changed:
sanitize_plugin_config deliberately does not HTML-escape strings, since
escaping at this layer would store the escaped form in config.json — the
docstring said "prevent injection", which read as a promise it does not
keep, and now says what it actually does. validate_font_awesome_class's
second 'fa-' check is unreachable behind its own regex; harmless, so
characterized rather than removed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 13:43:51 +00:00
Claude b6bab63614 test(web): cover the error and response builders, and stop dropping empty values
errors.py and error_handler.py's response builders had no direct tests,
though every API response passes through them. Two bugs surfaced.

WebInterfaceError set suggested_fixes with `or`, so a caller passing []
to mean "I have no suggestions for this one" got the default list
instead. Only None should fall back.

create_success_response gated `data` on `is not None` but `message` and
`metadata` on truthiness, so an explicitly-passed "" or {} vanished from
the response while 0 and False survived — the response shape depended on
the value. api_helpers.success_response() then re-gated metadata the same
way, which is the path every api_v3 endpoint actually calls, so fixing
only the inner function would have changed nothing observable. Both now
use `is not None`.

That wrapper also merged request timing into the caller's own metadata
dict in place. A caller reusing a dict across requests would accumulate
previous responses' timings; it now copies before adding.

79 tests: category inference for every error code, mapped vs fallback
suggestions, the JSON shape including which keys are omitted when empty,
exception-to-code inference, and the success/error builders end to end.
Two behaviours are pinned as deliberate rather than fixed: an empty
context stays out of the response body, and from_exception's `message`
is the fixed per-code string, never the raw exception text.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 13:41:59 +00:00
Claude 4fae11d7d1 test(logos): cover LogoHelper, and stop bad downloads poisoning the cache
Nothing in test/ referenced logo_helper.py, so its caching, resizing and
download-fallback logic was entirely unexercised. Two bugs surfaced.

_download_logo wrote response.content to disk with no size limit and no
check that the bytes were an image. A logo URL is remote input, so the
response chose how much went into the assets directory; worse, an
undecodable one stayed there, and because load_logo() only reports the
decode failure and returns None, every later call re-read the same
corrupt file. The download path never retried, so a single bad response
made a logo permanently blank rather than falling back to the
placeholder. Cap the response, verify it decodes, and delete it if not,
which lets the existing fallback in load_logo_with_download do its job.

get_cache_stats() divided by self.cache_size with no guard, so a helper
built with cache_size=0 raised ZeroDivisionError from what is only a
stats call.

37 tests: size-qualified cache keys, LRU eviction and refresh, the four
load_logo_with_download paths, download permissions and timeout,
placeholder generation, and the abbreviation normalizer — including a
test pinning its deliberate divergence from
LogoDownloader.normalize_abbreviation, since logo filenames on existing
installs depend on both behaviors staying put.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 13:39:31 +00:00
Claude 062bdf691f test(sync): cover the display sync protocol, and fix what that surfaced
DisplaySyncManager had no tests at all — it appeared in the suite only as
a MagicMock() stand-in, so none of its framing, handshake, or socket
handling was ever exercised. Writing that coverage surfaced three bugs.

Both receive loops caught the generic Exception and immediately retried.
A socket left in a bad state raises on every call, so the thread spun at
100% CPU logging the same line; the reverted-code run of the new
regression test takes 24 seconds where the fixed one takes 0.2. Both now
back off briefly before retrying.

The follower dispatched on `data[:8] == _RAW_MAGIC or len(data) > 512`.
That size threshold is not part of either wire format: a control message
over 512 bytes — a hello_ack carrying a long incompatibility error, for
instance — went to the image decoder and was dropped, and a raw frame
under 512 bytes went to the JSON parser. Both formats are already
self-describing, so dispatch on the magic prefix and treat a JSON parse
failure as the legacy unmarked PNG, with the shared frame bookkeeping
factored into _handle_received_frame().

_oversized_frame_warned was created on first use through
getattr(self, ..., False) rather than in __init__, alone among the
instance attributes.

75 tests: role parsing, the hello compatibility matrix, watchdog
timeouts, both receive loops, the TCP image server's length and
dimension caps and decompression-bomb guard, status shape per role, and
one end-to-end loopback handshake so the wire format is exercised for
real and not only against mocks.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-13 13:37:01 +00:00
9cf30bbbef fix(startup): bound the initial plugin update so the panel lights sooner (#456)
* fix(startup): bound the initial plugin update so the panel lights sooner

DisplayController.__init__ calls _update_modules() once to populate
plugin data before the first frame. It walks every loaded plugin in
turn, and each update blocks the calling thread for up to the executor's
30s timeout, so the uncapped total is the sum of every slow plugin on
the system. The rig's own log:

    Initial plugin update completed in 82.255 seconds
    Initial plugin update completed in 55.123 seconds
    Initial plugin update completed in 25.975 seconds

The panel shows nothing for all of it.

Nothing is lost by stopping early. A plugin that has never updated is
immediately due, so run_scheduled_updates() collects it seconds later --
with the display already running rather than blank.

A deadline alone was not enough: it is checked before each plugin, so
the last one to start could still block for the full 30s, and a 20s
budget produced a 31.8s pass on the rig. The remaining budget is now
passed down as that update's timeout too, with a floor so a plugin
starting on the last sliver is not handed ~0s and recorded as having
timed out for a slot it never had. Measured after: 20.006s.

Found while profiling a scroll freeze with py-spy, which caught the main
thread 9.34s inside execute_with_timeout's join. Worth being clear that
this is startup latency, not the recurring stutter -- _update_modules
has exactly one caller and runtime updates already run off the display
thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* feat(display): show the device address on the startup screen

That screen is what the panel holds for the whole initial plugin update,
and on a headless Pi it is the only place the address appears without
going looking for it -- so it now carries the address under
"Initializing".

The lookup connects a UDP socket, which sends no packets: it only asks
the kernel which source address it would route from. That costs 0.03ms
and works with the network down so long as a route exists. Deliberately
not `hostname -I` plus a systemctl probe for AP mode, which is how the
web launcher does it -- two subprocesses with multi-second timeouts, on
the startup path this branch exists to shorten.

Two things had to change for the address to be worth putting there.

The text is now sized to fit rather than fixed at 8px: "Initializing" is
96px in PressStart2P, drawn at x=10, so it already ran off the side of a
64px panel before an address was added. It falls back to 4x6 where that
does not fit, and both lines are centred.

And the test pattern is punched out from behind the block, with the text
drawn white rather than blue. The diagonal runs through the middle of
the panel, which is exactly where this sits, and blue on black reads
fine on a monitor but is marginal on a dim panel. An address that cannot
be read off the wall is not worth showing.

The rendering tests assert against pixels -- no green left behind the
text at any supported size, enough lit pixels to be visible -- rather
than against the geometry that produced them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(display): keep the startup text blue -- it is a channel reference

The test pattern lights one pure channel per element: red border, green
diagonal, blue text. That is how a glance at the panel tells you whether
led_rgb_sequence is right -- wire it BGR and the border comes up blue
and the text red. Drawing the text white, as the previous commit did for
contrast, lights all three channels and destroys the only blue reference
on the screen.

Reverted to blue, with the reason written down so it is not treated as a
style preference again, and with tests that pin it: the text must be
pure blue, nothing on the screen may be white, and all three primaries
must be present.

The punched-out backdrop stays. It only removes the diagonal from behind
the glyphs, which costs nothing diagnostically -- the diagonal is still
plainly visible across the rest of the panel -- and it is what makes the
address readable at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(startup): defer a plugin with too little budget, rather than clamp it

The per-plugin timeout was clamped up to a floor, so a plugin that began
with a sliver of budget left was granted the full floor and ran on past
the deadline: a 20s budget could take 22. The floor existed to stop a
plugin being handed a slot too short to use and then recorded as having
timed out, which is a real concern, but clamping solved it by breaking
the bound.

Deferring solves both. Below the floor the plugin is left to the update
tick, which was already the fate of everything after the deadline, so
nothing new is lost -- a plugin that has never updated is immediately
due. Above it, the timeout is the exact remainder, and the pass cannot
outlast its deadline.

Measured on the rig after the change: 20.002s, 5 plugins deferred.

Also names an unused binding in the initializing-screen test.

Both reported by CodeRabbit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:58:05 -04:00
a51fb7ce11 feat(vegas): make scroll stutter visible, and catch it in the act (#454)
* feat(vegas): make scroll stutter visible, and catch it in the act

The loop reported only a mean FPS over a five-second window. At 120fps
that is ~600 frames, so a 200ms freeze -- plainly visible on a marquee --
moves the average from 120.0 to 115.4 and reads as healthy. Stutter was
literally unmeasurable.

The FPS line now carries p99, the worst frame, and a hitch count. On the
dev rig that immediately turned "it sometimes stutters" into a number:
two freezes of 3.2s and 0.7s in twenty minutes, with every other frame
under 81ms.

Statistics say a stall happened but not what caused it, and by the time
they are logged the stack is gone. So there is also a watchdog that dumps
every thread's stack while the loop is still wedged. It is off unless
LEDMATRIX_STALL_WATCHDOG is set to a threshold in seconds, since it
prints a lot. Pointed at the 3.2s freeze it named the culprit on the
first try: a plugin generating a 17,000px scroll image, logo PNG decode
and all, synchronously on the render thread.

The hitch threshold is relative to what frames actually cost, not to the
configured target. The target is routinely set above what the panel can
hold so vsync does the pacing; measured against that budget every
ordinary frame counts as a hitch, and the first version of this counter
duly reported 250 per window on a display running perfectly smoothly.

The watchdog is owned by the coordinator, not created per iteration --
run_iteration is called repeatedly, so building one there would leak a
thread each time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(vegas): let the watchdog see stalls that hold the GIL

The watchdog only noticed a late heartbeat, which a whole class of
freeze can never produce: if the loop is inside one long C call that
holds the GIL, this thread cannot run during the stall, and by the time
it does the loop has already checked in. On the dev rig that hid a
recurring 3.2s freeze completely -- twenty minutes of watching produced
one dump, for an unrelated 0.4s stall.

What it can still observe is that its own sleep ran long. A badly
overshot wait is now reported as a stall in its own right. The stacks
are stale by then and the message says so, but knowing the freeze is
GIL-holding is most of the diagnosis: it rules out lock contention and
scheduling, and points at a single long C call.

This also explains why lowering sys.setswitchinterval changed nothing --
the switch interval cannot preempt a C call that never releases the GIL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* feat(vegas): report the worst frame, not just the mean

The loop logged only a mean FPS over a five-second window. At 120fps
that is ~600 frames, so a 200ms freeze -- plainly visible on a marquee
-- moves the average from 120.0 to 115.4 and reads as perfectly healthy.
Stutter was unmeasurable, which is why "it sometimes freezes" went
unpinned for so long.

Adding p99 and the worst frame turned that into a number immediately: on
the dev rig, two freezes of 3.2s and 0.7s in twenty minutes with every
other frame under 81ms. Not general slowness -- two rare, total stalls,
which is a different problem with a different fix.

Costs 0.96us per frame, about 0.012% of an 8.3ms frame.

This replaces an earlier version that also shipped a stall watchdog and
a hitch counter. The watchdog never found anything -- one dump in
forty-five minutes, for an unrelated stall -- because it can only notice
a late heartbeat, and the freeze happens in coordinator.start() before
the frame loop begins beating. py-spy found the cause in one recording
by sampling the process externally, which needs no code here. The hitch
counter went with it: it needed a rolling median every frame, which was
most of the cost, to produce a number the worst frame already tells you.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(vegas): use the nearest-rank index for p99

int(n * 0.99) is off by one, and at exactly 100 samples it selects the
maximum -- which is the number logged immediately beside it as the worst
frame. The two columns exist to say different things, p99 the
bad-but-ordinary frame and worst the outlier, so they agreed precisely
when the sample was smallest and least informative.

Nearest rank is ceil(n * fraction) - 1. Extracted so it can be tested
directly rather than only through a five-second logging interval.

Reported by CodeRabbit on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:06:32 -04:00
fce1fdac57 Add panel orientation setting for upside-down mounting (#455)
Adds a display.hardware.orientation config field ("normal" / "180")
so panels mounted upside down (e.g. to put the Pi/wiring on a more
convenient side) render correctly without custom pixel_mapper_config
edits. Composes onto the existing pixel_mapper_config as a trailing
"Rotate:180" mapper, so it stays independent of any custom mapper
string (e.g. U-mapper chain layouts) already in use.

Exposed as a "Panel Orientation" dropdown in the web UI's Display
settings, validated server-side, and documented in README and
CONFIG_REFERENCE.


Claude-Session: https://claude.ai/code/session_01FakipqMDHQLpsFjTuBdSFQ

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-12 09:40:48 -04:00
7171e6c022 fix(cache): one cleanup thread per cache directory, not per manager (#453)
The display process ran three cleanup threads over one directory:

    14:22:59.954  display_controller        (the real manager)
    14:22:59.973  startup validation, run 1 (discarded)
    14:23:01.055  startup validation, run 2 (discarded)

Two of those managers existed only to read a directory path.
StartupValidator._validate_cache_directory built a whole CacheManager to
call get_cache_dir(), and validation runs twice -- once before the
plugin manager exists and again after. Each construction also probes
writability by writing and deleting .writetest on the card.

The discarded ones never went away. cleanup_loop closes over `self`, so
the thread keeps its manager alive: two objects that could never be
collected, waking every 24 hours to re-scan the same 9,000-file
directory. Nothing stopped them either -- stop_cleanup_thread had no
callers anywhere in the tree.

Two changes. The validator now takes the CacheManager the application
actually uses, which is also the more correct thing to validate; when
no caller supplies one it still builds its own, but stops the thread
afterwards. And CacheManager now tracks which directory it is sweeping,
so the second manager over a directory skips starting a thread at all.
That is the right granularity regardless of call sites: the sweep lists
a directory and deletes from it, so a second thread only duplicates the
scan. Ownership is released on stop, so a survivor can take over rather
than leaving the directory permanently unclaimed by a dead owner.

Measured directly, three managers over one directory: 3 threads before,
1 after.


Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 08:42:56 -04:00
9fbdd71941 fix(cache): collect the temp files abandoned writes leave behind (#452)
* fix(cache): collect the temp files abandoned writes leave behind

DiskCache.set() writes through mkstemp then os.replace, and removes its
own temp file in a finally. That covers a write that fails, but not a
process that dies between the two -- a SIGKILL, a lost restart race, a
power cut, all ordinary on a Pi.

Nothing ever collected what was left. The temp names are
".<key>.json.<random>", and cleanup_expired_files listed only names
ending in .json, so every one of them was invisible to the sweep for as
long as the card had been in service. On the dev rig: 76 files,
1,050 MB, 81% of the whole cache directory, the oldest six months old.
The startup sweep reported "18/8864 files deleted, 0.01 MB freed" while
sitting on top of a gigabyte it could not see.

They are removed after an hour. A real write holds its temp file for
milliseconds, so that is far outside any in-flight write while still
clearing the same day's debris, and it is deliberately not tied to the
retention policies: those say how long data stays useful, and a
half-written file never was.

The predicate is tested harder than the sweep, because a false positive
deletes real data. It matches the shape set() creates rather than just a
leading dot, so a completed ".json", a stray .gitignore, and a
"weather.json.bak" are all left alone -- and one test drives set()
itself and asserts the names it produces are matched, so the writer and
the predicate cannot drift apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(cache): count swept temp files as scanned

files_scanned only counted completed .json files, so a sweep that
removed orphans reported more deleted than it had looked at -- the
summary line renders "<deleted>/<scanned>", which came out as "76/1".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 08:40:07 -04:00
2add759f40 fix(odds): identify the odds requests to ESPN (#451)
The odds fetch used a bare requests.get, so it went out as
python-requests/x.y -- the one agent ESPN is known to reject. Around
2026-08-04 it began 403ing browser strings and bare custom tokens alike;
what it accepts is a token carrying a URL that says who is calling.
Every other ESPN caller in the tree already sends that header
(src/common/api_helper.py, src/base_classes/data_sources.py); this path
was simply missed.

It is the worst one to miss. Odds are fetched per live game from inside
the live update loop, so its failures are the ones that cost the caller
its whole update budget -- the same path the 5s timeout and the cooldown
were added to protect.

Sent via a session rather than per-call, which also reuses the
connection across a slate. Deliberately no retry adapter, unlike
api_helper: retries multiply request_timeout, which is 5s precisely to
stay inside the 30s operation budget.

The existing tests patched the module's requests.get, which this change
bypasses -- test_base_odds_manager was consequently reaching the real
ESPN and taking 404s. Both files now patch the session, and the new
tests pin the agent against api_helper's live value so the two cannot
drift apart the next time ESPN moves the goalposts.


Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:10:56 -04:00
bb1a1671ec fix(cache): make the ttl parameter actually control expiry (#450)
CacheManager.set(key, data, ttl=...) stored the number and no read path
ever consulted it. Expiry came from a max_age inferred from substrings
in the key -- "live", "odds", "stock" -- so all 52 callers passing a ttl
were writing a value that did nothing. The docstring said so outright:
"stored for compatibility but expiration is still controlled via max_age
when reading". It is easier to read that as a note than as a defect,
which is presumably how it survived.

Both cache layers already hold the record when they decide, so each now
prefers an explicit ttl and falls back to max_age when there is none.
The caller that wrote the record knows what its data is; a substring
guess is a reasonable default for records that never said, and a poor
override for records that did.

Measured against a device's real cache of 8,875 entries carrying a ttl,
the inferred and intended values disagreed nearly everywhere:

    stocks    max_age  600  vs ttl    1800   4903 entries
    news      max_age 3600  vs ttl     600   1770 entries
    odds      max_age 1800  vs ttl    3600   1301 entries
    images    max_age  300  vs ttl 2592000     20 entries

In every case the ttl matches what the plugin plainly intended: stock
quotes cached for half an hour rather than ten minutes, headlines
refreshed every ten minutes rather than hourly, bird photographs that
never change kept for a month rather than five minutes.

Two things make this safe to land now. No sports_live entry carries a
ttl at all -- the live-score path does not use set(ttl=) -- so live
freshness is untouched, which matters with a season two weeks out. And
replaying the change against that real cache, 997 currently-expired
entries become live while not one live entry becomes expired, so there
is no invalidation spike on deploy.


Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:14:25 -04:00
8159afca43 fix(odds): stop a stalled ESPN taking the whole plugin update with it (#449)
Odds are fetched per live game from inside SportsLive.update(), with
show_odds defaulting on, and the plugin executor kills an operation at
30s. The odds request timeout was also 30s, so a single stalled request
consumed the entire budget and the update carrying every game's score
was killed.

Out of season that is invisible: preseason week 1 returns one game. A
Sunday slate is around sixteen, so the odds of at least one slow request
rise sharply just as the cost of losing the update does.

Shorten the request timeout to 5s, and after a network failure skip the
network for 60s. The timeout alone is not enough -- sixteen consecutive
5s timeouts still blow through -- and when ESPN is unreachable it is
unreachable for the whole slate, so the first failure already answers
the question for the rest of the pass.

    before: one stalled request = 30s = the entire budget
    after : 5s, the rest of the slate skipped, retry after 60s

The stale-cache fallback is unchanged: the cache is consulted before any
of this, and the failing request still falls back to it.

An earlier version of this branch also jittered the cache TTL to stagger
expiry across a slate. That has been dropped: CacheManager.set() stores
ttl for compatibility but the read path expires entries by a per-type
max_age (1800s for odds), so the jitter was inert. Making the read path
honour a per-entry ttl is a real fix but changes a contract 48 plugin
call sites already rely on, which is not a change to make two weeks
before the season.


Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:56:53 -04:00
44f59ede07 fix(web): say what actually went wrong instead of "unknown" (#448)
* fix(web): say what actually went wrong instead of "unknown"

Every failing endpoint returned "An error occurred; see logs for
details" and nothing else. That is survivable until the logs are the
thing you cannot reach: a device whose SD card was failing answered the
restart action, /system/status and /logs with that same sentence -- the
log viewer included, because journalctl could not be executed -- while
the exception underneath said

    [Errno 5] Input/output error: 'systemctl'

which names the fault outright. The only endpoint that helped was
/health, and only because it happens to pass a subprocess's stderr
through. Diagnosis came down to guessing which endpoint leaked something.

Add describe_exception(), returning "TypeName: message" on one line, and
populate the `details` field that the response schema has always had and
nothing ever filled. The type alone carries information -- a bare
PermissionError says more than any generic sentence.

Exception text is not automatically safe to echo: a requests error
quotes the URL it failed on, and plugins that authenticate by query
string put their key there. Credential values are redacted while the
parameter name is kept, since knowing which credential was involved is
part of the diagnosis. Length is capped and newlines collapsed so a
parser's context cannot flood a JSON field.

Nine handlers in api_v3 bound the exception and never used it, so the
promised log entry was never written either -- "see logs for details"
was false, not merely unhelpful. Those now log with a traceback and
carry the detail. The other 60 already logged and are unchanged; they
can adopt the helper as they are touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(web): redact auth headers and URL userinfo, and cover every handler

Three review findings.

The sanitizer missed two credential shapes that requests puts in its
exception text verbatim: `Authorization: Bearer <token>` and
`https://user:password@host`. Both would have gone straight into a
response. The auth-scheme name and the username are kept -- they say
which credential and whose without being the secret.

The AST test only asked whether *something* had been logged, so a
`logger.info("failed")` satisfied it while discarding the exception just
as completely. It now requires an error-level record carrying exc_info
and `describe_exception()` called on the handler's own bound exception.

Enforcing that revealed the first cut had scoped itself wrongly. I had
converted the nine handlers that logged nothing and left the sixty that
logged, reasoning their detail was at least in the journal. But
/system/status is one of the sixty, and on the failing device it told me
nothing -- the journal was exactly what could not be read. Splitting
them left most of the diagnostic surface unhelpful for the case this
change exists for, so all sixty-nine now carry the detail.

Two handlers had no bound exception name, and three passed the message
through a variable rather than a literal; both shapes needed doing by
hand. Full suite: 2383 passed, one pre-existing unrelated failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(web): stop reporting client errors as server faults

Werkzeug's HTTPExceptions subclass Exception, so the catch-all handler
saw them too and turned every 405, 400, 413 and 415 into a 500
UNKNOWN_ERROR. A GET on a POST-only route answered "an error occurred;
see logs for details", which tells the caller nothing and blames the
wrong side -- found while probing a device whose POST-only config
endpoints did exactly that.

Hand HTTPExceptions back as themselves, with their own status and
description. A genuine server fault still reports as one, with the
detail this branch adds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(web): redact any auth scheme, and require the detail in the response

Two review findings.

The auth-header pattern listed Bearer, Basic, Digest and Token, so
`Authorization: ApiKey SECRET` or `Negotiate SECRET` went to the client
intact. A fixed list silently leaks whatever it does not name, and
plugin APIs invent their own schemes, so match any scheme name and keep
it while redacting the credential.

The AST test accepted a describe_exception(e) call anywhere in the
handler, which a handler could satisfy by computing the detail and
dropping it before returning the generic message. It now requires the
call inside every return expression, which is where it has to be to
reach the caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:56:16 -04:00
ca26c1b83b fix(vegas): stop the width cap emitting fragments and stale windows (#446)
* fix(vegas): stop the width cap emitting fragments and stale windows

Two defects in the rotation that narrows an oversized plugin to its
width budget. Both were found while investigating "cut off early /
starts in the middle" reports and are the reason the cap is no longer
on by default; they still bite anyone who sets one.

A rotation's last window was whatever happened to be left over. Windows
are placed by walking forward from the previous one, with nothing
looking at the remainder, so a 1,840px stocks ticker against a 1,536px
budget split 1,492 + 348 -- every other appearance showed seven seconds
and cut. Absorb a remainder below half a budget into the window before
it. That overruns the budget by at most half, which is the better trade:
the budget guards against one plugin holding the panel for minutes, not
against a 20% overshoot. The floor is measured against the budget rather
than the panel because snapping to item boundaries already lands an
ordinary window short of it -- a 512px budget over 182px-pitch items
yields 348px windows, so an absolute floor merges windows that were
never fragments.

The stored offset also outlived the content it was recorded against. It
was a pixel column, reused verbatim after the plugin re-rendered, so
once anything ahead of it changed width the window pointed at unrelated
items -- observed as news refreshing 9,793px -> 9,505px mid-rotation.
Track the rotation as an index into the strip's item boundaries instead,
since the Nth boundary survives a digit appearing in a price, and record
alongside it what the offset indexes into: a row list, a boundary list,
or a column in a gapless image. A mismatch restarts the rotation rather
than reinterpreting the number, which also closes the case where one
plugin's row index was read back as a pixel column after its content
changed from several rows to one wide strip.

Replaying the four plugins that actually hit the cap on a live 512px
panel: no window is now a fragment, none exceeds 1.5 budgets, and every
rotation still covers the whole strip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(vegas): apply the runt floor to the multi-row rotation too

The floor only guarded the single-image path. I had reasoned the row
path could not produce a runt because it wraps, which is wrong: wrapping
only helps when the row wrapped to actually fits. Rows of 450, 450 and
100 against a 512px budget give the 100 a pass of its own -- two seconds
against nine, which is the symptom this branch exists to remove.

Reproduced before changing anything:

    pass 1: 450px    pass 2: 450px    pass 3: 100px

A window may now overrun the budget while it is still shorter than the
floor, bounded at the same 1.5 budgets the single-image path allows, so
the short row is carried with its neighbour instead of standing alone.

    pass 1: 450px    pass 2: 450px    pass 3: 550px

A next row too wide to absorb within that cap still leaves a short
window standing -- rows of 900 and 100 keep alternating. Merging them
would mean a window of nearly two budgets, and the rule that always
shows an oversized first row already makes the same trade.

Three regression tests: the reported shape, that the overrun stays
bounded when a row cannot be absorbed, and that absorbing never drops a
row from the rotation. The single-image path is untouched -- the four
plugins that actually hit the cap on a live panel replay identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 09:41:25 -04:00
f887063434 test(harness): flag a mode that draws nothing without reporting it (#447)
The controller skips a mode whose display() returns False and treats
anything else -- including None -- as "content was shown". A mode that
draws nothing and does not return False is therefore never skipped, and
because a mode switch clears the panel first, it sits on a blank screen
for its whole display duration. Two sports plugins shipped exactly that.

The harness rendered those modes and passed them, because it called
display() and discarded the result. Capture it, and warn when a render
produced no lit pixels while claiming content.

Warn-only by default, and deliberately so: a scroll mode's first frame
is legitimately its blank scroll-in buffer, which is 42 of these on the
F1 scoreboard alone. Plugins whose modes are known to draw on their
fixture data can opt into failing via harness.json {"empty_check":
"strict"}, matching how the fill check is staged.

Worth being clear about the limit: this only sees what the fixtures
render. It would not have caught the sports bug, whose fixture seeds
games so the empty path never renders -- that needs the source-level
gate in the plugins repo. What it does catch is the same mistake in any
plugin whose empty state the harness does happen to reach, which is
coverage there was none of before.


Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 08:17:57 -04:00
6287acd591 fix(vegas): stop capping plugin width by default (#445)
* fix(vegas): stop capping plugin width by default

Vegas plugins read as "cut off early" or "starting in the middle". That
was the per-plugin width budget, not the scroll engine:
overflow_mode=rotate is designed to resume mid-content on each
appearance, so the symptom was the feature working as specified.

Measured over a 17-plugin fleet on a 512px panel, the cap was a bad
trade. Only four plugins were ever wide enough to hit the 3.0 default --
leaderboard 11,518px, news 10,021px, odds-ticker 4,643px, hockey
1,508px. Weather is 650px and flights 512px; the cap never touched them
or the other eleven. So it bought nothing on thirteen plugins while
costing two visible faults on four: content entering mid-item (a news
ticker started at column 6027 of its own strip), and a final rotation
window of whatever happened to be left -- 348px of an 1,840px stocks
ticker, seven seconds of panel time.

Default max_plugin_width_ratio to 0 (uncapped), so every plugin
contributes all of its content and is always entered at its beginning.
The cap remains available, and vegas_max_width_screens still caps an
individual plugin -- which is where the knob belongs, since a genuinely
long ticker is a property of that plugin rather than of the fleet.

Verified on a live 512px device: 327 budget crops in the preceding six
hours, none after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* test(vegas): cover the width cap still working when asked for

Defaulting the cap off must not quietly remove it. Asserts that an
explicit ratio is honoured and validates, alongside the existing check
that omitting it means uncapped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:17:28 -04:00
ee59caa577 Follow-ups from #441: secret-helper migration, ten more bug fixes, and coverage for every remaining untested module (#444)
* refactor(web): use canonical secret helpers in api_v3; make ConfigManager secret strip/merge array-aware

api_v3.py carried three inline nested copies of find_secret_fields/
separate_secrets (main-config save, plugin-config save, plugin-config
reset). They drifted from each other (one lacked isinstance guards) and
none supported the canonical module's array-item secrets
(accounts[].token). All three endpoints now import from
src/web_interface/secret_helpers.

Adopting the canonical behavior makes array-item secrets reachable, and
their parallel-placeholder shape ([{'token': ...}, {}] alongside the
regular list) was not survivable by ConfigManager's round-trip:
_strip_secrets_recursive dropped the whole key (losing the regular
fields from config.json) and _deep_merge replaced the regular list
wholesale on load. Both are now array-aware:

- strip removes the secret fields from each item and ALWAYS keeps the
  list so indices survive for merge-on-load; whole-key secrets (scalar
  lists, shape mismatches) still drop the key entirely — never leak.
- merge folds each secrets item into the config item at the same index,
  skipping {} placeholders. The regular list's length is authoritative
  in both directions: a user deleting an array item never has it
  resurrected from a stale secrets entry (extras warn and are ignored).

api_v3's own deep_merge intentionally still replaces lists wholesale —
form posts carry complete arrays and index-merging would resurrect
deleted items; a comment now documents that.

Tests: the parity guard flips from 'exactly 3 inline copies' to 'zero,
and the canonical import must exist'; TestArraySecretStripAndMerge
covers the new strip/merge semantics incl. length-mismatch contracts;
new test_api_v3_secret_roundtrip.py drives all three endpoints through
a Flask client with a REAL ConfigManager+SchemaManager over tmp_path,
proving secrets land in config_secrets.json, config.json stays clean,
and a fresh load merges them back into the right array items.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* fix: repair broken helper paths across display, cache, odds, logging, resolver, repos, config, validator

Nine fixes for bugs surfaced while writing coverage for previously
untested modules (plus the bool-duration quirk pinned in PR #441):

- base_plugin.get_display_duration: exclude bools from both numeric
  branches — display_duration=True no longer reads as a 1-second slot;
  it falls through to config, then the 15.0 default.
- display_helper: draw_error_message/draw_no_data_message called
  _draw_centered_text with the wrong arguments and crashed with
  AttributeError — both now delegate to draw_centered_text.
  draw_scorebug_layout drew status and clock at the same y, overprinting
  each other — they now share one combined top line.
  draw_ticker_layout drew its text starting at x=display_width (fully
  off-canvas), returning a blank frame every time — now draws at x=0;
  scroll_speed stays accepted-but-unused and is documented as such.
- api_helper.clear_cache guarded on a nonexistent CacheManager.clear()
  method, silently never clearing anything; it now uses the real surface
  (clear_cache/delete/list_cache_files) and no-ops safely otherwise.
- base_odds_manager._extract_espn_data raised AttributeError when ESPN
  sent explicit JSON nulls ("homeTeamOdds": null) — every level now
  null-safes with 'or {}'. format_odds_summary gated on
  is_odds_available, which deliberately ignores money lines, so
  ML-only odds formatted as "No odds available" — it now gates only on
  empty/no_odds data and formats money lines.
- logging_config.ContextualFormatter mutated record.msg in place, so a
  second handler prepended the context prefix twice; it now formats a
  copy. log_error hardcoded exc_info=True and raised TypeError when the
  caller passed exc_info — now kwargs.setdefault.
- dynamic_team_resolver wrote its "shared" class cache through self,
  creating instance shadows — the cache was per-instance and every
  scoreboard refetched rankings. Writes now go through the class.
- saved_repositories cleaned URLs with an unanchored .replace('.git','')
  that mangled URLs merely containing '.git' (my.github.io -> myhub.io);
  now strips only a trailing suffix. add/remove also roll back the
  in-memory list when the save fails, so memory always matches disk.
- config_helper.merge_configs shallow-copied the base, aliasing every
  un-overridden nested dict into the result — now deep-copies.
- startup_validator.validate_all accumulated errors/warnings across
  calls — now resets both lists per run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* test: cover the previously untested modules

Nine new suites plus an extension, asserting the Phase-1b fixed behavior
and pinning the quirks deliberately left alone:

- test_logging_config.py: formatters (JSON shape, no record mutation,
  single prefix through two handlers), PluginLoggerAdapter precedence,
  setup_logging handler hygiene and LEDMATRIX_DEBUG, log_error exc_info.
- test_startup_validator.py: exact messages, error-vs-warning split,
  accessor split (load_config vs get_config), cache-dir branches with
  os.access monkeypatched (root can write anything in CI), idempotence,
  raise_on_errors classification precedence.
- test_config_helper.py (full): load/save round trips, dot-notation
  get/set incl. silent-failure contract, post-fix no-aliasing merge,
  schema validation branches, the '{id}_config' key pin, default-enabled
  pin.
- test_saved_repositories.py: three load shapes, bare-list rewrite pin,
  trailing-only .git strip (my.github.io regression), save-failure
  rollback, type-classification case-sensitivity pin.
- test_api_helper.py: rate-limit math, cache-hit short circuit, ESPN
  URL/key formats, exact User-Agent guard, retry adapter, post-fix
  clear_cache against the real CacheManager surface, ttl-dropped pin.
- test_base_odds_manager.py: cache-key/URL construction, no_odds
  sentinel round trip, stale-cache fallback, null-safe extraction,
  ML-only formatting, is_odds_available truth table (ML-blind by
  contract), config key/attr mismatch pin.
- test_dynamic_team_resolver.py: expansion/dedup/slicing, dropped
  unknown-dynamic names (TOP_ substring hazard pinned), genuinely
  shared class cache (second instance: zero HTTP), TTL expiry,
  failure degradation without raising.
- test_display_helper.py (full): the fixed error/no-data renders,
  combined scorebug top line, non-blank ticker with scroll_speed
  no-op pin, composite upconversion, logo bleed positions, square
  orientation pin.
- test_skin_runtime_cache.py: discovery-cache hit/invalidation
  semantics (manifest mtime, .py edits pinned as non-invalidating),
  sys.modules namespacing contract incl. bare-name restore and stdlib
  shadowing, entry-module execute-once, API minor-version tolerance,
  skin_matches_target table.
- test_sports_capabilities.py (extended): _draw_celebration_layout
  executed for real (flash window, matrix-dims fallback, highlight
  alternation, logo-failure isolation), _should_celebrate_for direct,
  strict duration boundary, score_to_int edges, both-teams-score
  precedence, expired-coalesce refire, disabled-win baseline
  preservation, id-less prune.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* test: real schedule/dim coverage for DisplayController; fix two vacuous schedule tests

New test_display_controller_schedule.py drives _check_schedule and
_check_dim_schedule on a bare controller stub: same-day and
midnight-crossing windows with inclusive boundaries, global vs per-day vs
legacy-inferred modes (and dim's global-only default — no legacy
inference), per-day disabled days, invalid %H:%M fallbacks, unknown
timezone -> UTC, dim_brightness default 30, inactive-display short
circuit, and the _was_display_active/_was_dimmed transition flags.

test_display_controller.py's test_schedule_disabled and
test_active_hours patched config_service.get_config — which
_check_schedule never reads — so both asserted the init-default value
and could not fail. Rewritten on the test_inactive_hours pattern
(inject controller.config['schedule'], reset the minute gate, flip the
flag to the opposite state first so the assertion has teeth).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* ci: raise coverage floor to 48%

Measured 50% with the new suites in place (was 47% baseline when the
gate was introduced at 45); floor stays two points under measured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* fix: address CodeQL alert and review findings

- config_manager: the "secrets list longer than config list" warning now
  interpolates only config-side data (no key name or secrets-derived
  values), resolving the CodeQL clear-text-logging alert.
- base_plugin: validate_config rejects bool display_duration, matching
  get_display_duration (bool is an int subclass and would otherwise pass
  as a positive number).
- config_helper: merge_configs deep-copies override values in the
  non-recursive branch so mutating the merged result cannot reach back
  into override_config.
- saved_repositories: saves are atomic (temp file + fsync + os.replace),
  so a failed write can no longer truncate saved_repositories.json.
- tests: regression cases for each fix, plus a pin that whole-item
  array secrets (key[] + key[].field both marked) strip to empty {}
  skeletons — no secret values can reach config.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 16:17:11 -04:00
75 changed files with 10972 additions and 545 deletions
+1 -1
View File
@@ -72,4 +72,4 @@ jobs:
--ignore=test/plugins \
--cov=src --cov=web_interface \
--cov-report=term \
--cov-fail-under=45
--cov-fail-under=52
+8
View File
@@ -600,6 +600,14 @@ These settings are typically only needed for non-standard panels or custom confi
- Leave empty unless you need custom mapping
- See rpi-rgb-led-matrix documentation for full options
- **`orientation`** (string, default: "normal")
- Rotates the rendered image to match how the panel is physically mounted
- Set to `"180"` (or use the "Upside Down" option in the web UI's Display
settings) if the panel is mounted upside down — useful for optimizing
where the Raspberry Pi and wiring sit relative to the mounting location
- Applied independently of `pixel_mapper_config` (appended as a trailing
`Rotate:180` mapper), so custom mapper configs keep working alongside it
- **`row_address_type`** (integer, default: 0)
- How rows are addressed on the panel
- Most panels use 0 (direct addressing)
+2 -1
View File
@@ -112,6 +112,7 @@
"led_rgb_sequence": "RGB",
"limit_refresh_rate_hz": 100,
"pixel_mapper_config": "",
"orientation": "normal",
"row_address_type": 0,
"multiplexing": 0,
"panel_type": ""
@@ -149,7 +150,7 @@
"min_plugin_width": 8,
"lead_in_width": 0,
"plugins_per_cycle": 6,
"max_plugin_width_ratio": 3.0,
"max_plugin_width_ratio": 0.0,
"overflow_mode": "rotate",
"dynamic_duration_enabled": true,
"min_cycle_duration": 60,
+2 -1
View File
@@ -66,6 +66,7 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270295).
| `led_rgb_sequence` | string, `"RGB"` |
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
| `orientation` | string, `"normal"``"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value |
| `row_address_type` | int, `0` — non-standard panel row addressing |
| `multiplexing` | int, `0` — panel multiplexing scheme |
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
@@ -127,7 +128,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `min_plugin_width` | int, `8` |
| `lead_in_width` | int, `0` |
| `plugins_per_cycle` | int, `6` |
| `max_plugin_width_ratio` | float, `3.0` |
| `max_plugin_width_ratio` | float, `0.0` |
| `overflow_mode` | string, `"rotate"` |
| `dynamic_duration_enabled` | bool, `true` |
| `min_cycle_duration` | int, `60` |
+15
View File
@@ -41,6 +41,7 @@ from src.plugin_system.testing.loading import ( # noqa: E402
)
from src.plugin_system.testing.harness import ( # noqa: E402
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
check_empty_claimed,
check_scale_up,
)
from src.plugin_system.testing.sizes import ( # noqa: E402
@@ -115,6 +116,11 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
fill_strict = spec.get("fill_check") == "strict"
# A mode that renders nothing without returning False is never skipped by
# the display controller, so it holds a blank panel for its whole duration.
# Warn-only by default: a scroll mode's first frame is legitimately its
# blank scroll-in buffer.
empty_strict = spec.get("empty_check") == "strict"
# Every run: the base config, plus one per harness.json "variant" —
# a config overlay with its own golden dir (e.g. adaptive layout mode
@@ -142,6 +148,7 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
compare_to_goldens(results, golden_dir)
check_scale_up(results, design_size=design_size, strict=fill_strict)
check_empty_claimed(results, strict=empty_strict)
# Tag variant runs so the report and PNG dumps stay distinguishable.
if variant_name:
@@ -178,6 +185,9 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
# warn-only underfill: big panel left mostly empty
ex, ey = r.fill_extent
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
if r.empty_claimed and r.empty_ok is None:
detail += (f" (empty warn: drew nothing but display() returned"
f" {r.display_returned!r}, so the mode is not skipped)")
else:
everything_ok = False
if r.error is not None:
@@ -191,6 +201,11 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
ex, ey = r.fill_extent or (0.0, 0.0)
status = "FAIL"
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
elif r.empty_ok is False:
status = "FAIL"
detail = (f" drew nothing but display() returned"
f" {r.display_returned!r}; return False so the"
f" controller skips the mode")
else:
status, detail = "FAIL", ""
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
+71 -10
View File
@@ -12,6 +12,8 @@ Follows LEDMatrix configuration management patterns:
"""
import logging
import time
import requests
import json
from typing import Dict, Any, Optional, List
@@ -43,9 +45,34 @@ class BaseOddsManager:
self.logger = logging.getLogger(__name__)
self.base_url = "https://sports.core.api.espn.com/v2/sports"
# This path used a bare requests.get, so it identified itself as
# python-requests/x.y -- the one thing ESPN is known to reject. Around
# 2026-08-04 it began 403ing browser strings and bare custom tokens
# alike; what it accepts is a token with a URL that says who is
# calling. Every other ESPN caller in the tree already sends this
# (src/common/api_helper.py, src/base_classes/data_sources.py); the
# odds path was simply missed, and it is the one whose failures cost
# the caller its whole update budget.
#
# Deliberately no retry adapter, unlike api_helper: retries multiply
# request_timeout, which is set to 5s precisely to stay inside that
# budget. One try, then the cooldown below.
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
'Accept': 'application/json',
})
# Configuration with defaults
self.update_interval = 3600 # 1 hour default
self.request_timeout = 30 # 30 seconds default
# Well under the plugin executor's 30s operation budget. At 30s a
# single stalled ESPN request consumed the entire budget and the whole
# update() was killed -- and odds are fetched per live game, inside the
# live update loop, with show_odds defaulting on. Losing one game's
# odds beats losing the update that carries every game's score.
self.request_timeout = 5
# Set when a request fails; until then, skip the network entirely.
self._skip_network_until = 0.0
self.cache_ttl = 1800 # 30 minutes default
# Load configuration if available
@@ -73,6 +100,14 @@ class BaseOddsManager:
except Exception as e:
self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}")
# After a network failure, stop trying for this long and serve cache only.
# A short per-request timeout bounds one stall, but a full Sunday slate is
# ~16 games fetched in a loop, so 16 consecutive timeouts still blow the
# budget. When ESPN is unreachable it is unreachable for all of them, so
# the first failure is enough to know: skip the rest of this pass and try
# again shortly.
_FAILURE_COOLDOWN = 60.0
def get_odds(self, sport: str | None, league: str | None, event_id: str,
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]:
"""
@@ -101,6 +136,16 @@ class BaseOddsManager:
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
return cached_data
if time.monotonic() < self._skip_network_until:
# A recent request failed, so ESPN is very likely still unreachable.
# Returning now keeps the caller's update inside its time budget
# instead of paying the timeout again for every remaining game.
self.logger.debug(
"Skipping odds fetch for %s: a recent request failed, holding off "
"for another %.0fs", cache_key,
self._skip_network_until - time.monotonic())
return None
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
try:
@@ -117,10 +162,12 @@ class BaseOddsManager:
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
self.logger.info(f"Requesting odds from URL: {url}")
response = requests.get(url, timeout=self.request_timeout)
response = self.session.get(url, timeout=self.request_timeout)
response.raise_for_status()
raw_data = response.json()
self._skip_network_until = 0.0 # reachable again
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
odds_data = self._extract_espn_data(raw_data)
@@ -140,7 +187,11 @@ class BaseOddsManager:
return odds_data
except requests.exceptions.RequestException as e:
self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}")
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
self.logger.error(
"Error fetching odds from ESPN API for %s: %s. Holding off on odds "
"for %.0fs so a slate of games does not pay this timeout each.",
cache_key, e, self._FAILURE_COOLDOWN)
except json.JSONDecodeError:
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
@@ -163,19 +214,25 @@ class BaseOddsManager:
item = data["items"][0]
self.logger.debug(f"First item keys: {list(item.keys())}")
# The ESPN API returns odds data directly in the item, not in a providers array
# Extract the odds data directly from the item
# The ESPN API returns odds data directly in the item, not in a
# providers array. ESPN sends explicit JSON nulls for absent
# sides ("homeTeamOdds": null), so every level uses `or {}` —
# .get's default only applies when the key is missing entirely.
home = item.get("homeTeamOdds") or {}
away = item.get("awayTeamOdds") or {}
extracted_data = {
"details": item.get("details"),
"over_under": item.get("overUnder"),
"spread": item.get("spread"),
"home_team_odds": {
"money_line": item.get("homeTeamOdds", {}).get("moneyLine"),
"spread_odds": item.get("homeTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
"money_line": home.get("moneyLine"),
"spread_odds": ((home.get("current") or {})
.get("pointSpread") or {}).get("value")
},
"away_team_odds": {
"money_line": item.get("awayTeamOdds", {}).get("moneyLine"),
"spread_odds": item.get("awayTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
"money_line": away.get("moneyLine"),
"spread_odds": ((away.get("current") or {})
.get("pointSpread") or {}).get("value")
}
}
self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}")
@@ -260,7 +317,11 @@ class BaseOddsManager:
Returns:
Formatted odds summary string
"""
if not self.is_odds_available(odds_data):
# Gate only on truly-empty / negative-cached data. is_odds_available
# deliberately ignores money lines (its callers decide whether to
# RENDER an odds widget), but a summary of money-line-only odds is
# still meaningful — the parts loop below handles them.
if not odds_data or odds_data.get('no_odds'):
return "No odds available"
parts = []
+80 -1
View File
@@ -14,6 +14,13 @@ import zlib
from typing import Dict, Any, Optional, Protocol
from datetime import datetime
# How old an abandoned write's temp file must be before the sweep removes it.
# A real write holds its temp file for milliseconds, so an hour is far beyond
# any in-flight write while still clearing the same day's debris. Deliberately
# not tied to the retention policies: those describe how long data stays
# useful, and a half-written file was never useful.
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
class CacheStrategyProtocol(Protocol):
@@ -112,6 +119,22 @@ class DiskCache:
record_ts = None
now = time.time()
# An explicit per-entry ttl wins over the caller's max_age. The
# caller that wrote the record knows what its data is; max_age is
# inferred from substrings in the key ("live", "odds", "stock") and
# is only a fallback for records that never said. Until now the ttl
# was stored and ignored, so `set(key, data, ttl=...)` did nothing
# at all -- 48 plugin call sites and 4 in the core were writing a
# number no read path consulted.
effective_max_age = max_age
if isinstance(record, dict):
stored_ttl = record.get('ttl')
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
and stored_ttl >= 0:
effective_max_age = stored_ttl
max_age = effective_max_age
# max_age=None means "never expires" (mirrors MemoryCache and the
# cache_manager docstring). Guard it explicitly — otherwise the
# comparison below raises TypeError and the record is treated as a
@@ -331,6 +354,23 @@ class DiskCache:
"""Get the cache directory path."""
return self.cache_dir
@staticmethod
def _is_orphaned_temp(filename: str) -> bool:
"""Whether a name is one of set()'s temp files rather than real data.
Matches only what this class creates: mkstemp with a prefix of
".<cache filename>." , so ".weather.json.a1b2c3d4". The shape is
checked rather than just the leading dot, because this predicate
deletes things -- a stray dotfile someone left in the cache directory
is not ours to remove, and a completed ".json" never is either.
"""
if not filename.startswith('.') or filename.endswith('.json'):
return False
head, sep, suffix = filename.rpartition('.json.')
# head is the key (non-empty after the leading dot), suffix is
# mkstemp's random component.
return bool(sep) and len(head) > 1 and bool(suffix)
def cleanup_expired_files(self, cache_strategy: CacheStrategyProtocol, retention_policies: Dict[str, int]) -> Dict[str, Any]:
"""
Clean up expired cache files based on retention policies.
@@ -365,12 +405,51 @@ class DiskCache:
try:
with self._lock:
# Get snapshot of files while holding lock briefly
filenames = [f for f in os.listdir(self.cache_dir) if f.endswith('.json')]
entries = os.listdir(self.cache_dir)
except OSError as list_error:
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
stats['errors'] += 1
return stats
filenames = [f for f in entries if f.endswith('.json')]
# Sweep temp files abandoned by a write that never finished. set()
# removes its own in a finally, so these are the ones where the
# process died between mkstemp and os.replace -- a SIGKILL, a lost
# restart race, a power cut. Nothing ever collected them: they are
# named ".<key>.json.<random>", and the scan above only matches
# names ending in .json, so they accumulated indefinitely. Measured
# on a live rig: 76 files, 1,050 MB, 81% of the whole cache
# directory, the oldest six months old.
stats['orphan_temp_files_deleted'] = 0
for filename in (f for f in entries if self._is_orphaned_temp(f)):
# Counted as scanned like any other candidate, so files_deleted
# can never exceed files_scanned and the summary line reads
# honestly ("77/8864", not "77/0").
stats['files_scanned'] += 1
path = os.path.join(self.cache_dir, filename)
try:
# An in-flight write lives for milliseconds, so anything
# this old is certainly abandoned rather than in progress.
if (current_time - os.path.getmtime(path)) <= _ORPHAN_TEMP_MAX_AGE_SECONDS:
continue
with self._lock:
size = os.path.getsize(path)
os.remove(path)
stats['files_deleted'] += 1
stats['orphan_temp_files_deleted'] += 1
stats['space_freed_bytes'] += size
except FileNotFoundError:
continue # another sweep got there first
except OSError as e:
stats['errors'] += 1
self.logger.warning("Error deleting orphaned temp file %s: %s", filename, e)
if stats['orphan_temp_files_deleted']:
self.logger.info(
"Removed %d abandoned cache temp file(s)",
stats['orphan_temp_files_deleted'])
# Process files outside the lock to avoid blocking get/set operations
for filename in filenames:
stats['files_scanned'] += 1
+10
View File
@@ -57,6 +57,16 @@ class MemoryCache:
if timestamp is None:
return None
# An explicit per-entry ttl wins over the caller's max_age, matching
# DiskCache. max_age is inferred from substrings in the key and is
# only a fallback for records that did not say what they wanted.
record = self._cache[key]
if isinstance(record, dict):
stored_ttl = record.get('ttl')
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
and stored_ttl >= 0:
max_age = stored_ttl
# Check expiration
if max_age is not None and (now - timestamp) > max_age:
# Expired - remove it
+44 -3
View File
@@ -47,6 +47,20 @@ from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-e
class CacheManager:
"""Manages caching of API responses to reduce API calls."""
# Which cache directories already have a cleanup thread in this process.
#
# The sweep is directory-scoped work -- it lists a directory and deletes
# from it -- so one per directory is the right number no matter how many
# managers exist. Nothing enforced that before: every instance started its
# own, and because the loop closes over `self`, a discarded manager could
# never be collected and its thread woke to re-scan the same directory
# every 24 hours for the life of the process. Startup validation runs
# twice and built a throwaway manager each time, so a display process
# carried three threads for one cache.
_cleanup_owners: Dict[str, 'CacheManager'] = {}
_cleanup_owners_lock = threading.Lock()
def __init__(self) -> None:
# Initialize logger first
self.logger: logging.Logger = get_logger(__name__)
@@ -594,8 +608,10 @@ class CacheManager:
Args:
key: Cache key
data: Data to cache
ttl: Optional time-to-live in seconds (stored for compatibility but
expiration is still controlled via max_age when reading)
ttl: Time-to-live in seconds for this entry. Takes precedence over
the max_age a reader would otherwise apply, which is inferred
from the key and is only a fallback for entries that did not
say. Omit it to keep that inferred behaviour.
"""
cache_data = {
'data': data,
@@ -716,11 +732,29 @@ class CacheManager:
}
def start_cleanup_thread(self) -> None:
"""Start background thread for periodic disk cache cleanup."""
"""Start background thread for periodic disk cache cleanup.
At most one thread per cache directory per process: the sweep is
directory-scoped, so a second one only duplicates the scan.
"""
if self._cleanup_thread and self._cleanup_thread.is_alive():
self.logger.debug("Cleanup thread already running")
return
with CacheManager._cleanup_owners_lock:
owner = CacheManager._cleanup_owners.get(self.cache_dir)
if owner is not None and owner is not self:
thread = owner._cleanup_thread
if thread is not None and thread.is_alive():
self.logger.debug(
"Cleanup thread for %s already owned by another cache "
"manager in this process; not starting a second",
self.cache_dir)
return
# The owner's thread died or was stopped -- take over.
CacheManager._cleanup_owners[self.cache_dir] = self
def cleanup_loop():
"""Background loop that runs cleanup periodically."""
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
@@ -768,6 +802,13 @@ class CacheManager:
Signals the thread to stop and waits for it to finish (with timeout).
This allows for clean shutdown during testing or application termination.
"""
# Release ownership first and unconditionally, so a manager that never
# started a thread (or whose thread already exited) cannot keep the
# directory claimed and block a live manager from sweeping it.
with CacheManager._cleanup_owners_lock:
if CacheManager._cleanup_owners.get(self.cache_dir) is self:
del CacheManager._cleanup_owners[self.cache_dir]
if not self._cleanup_thread or not self._cleanup_thread.is_alive():
self.logger.debug("Cleanup thread not running")
return
+26 -11
View File
@@ -273,19 +273,34 @@ class APIHelper:
"""
Clear cache data.
Uses CacheManager's real surface (clear_cache / delete /
list_cache_files); safely no-ops on managers without it. The old
implementation guarded on a nonexistent ``clear`` method, so it
silently never cleared anything.
Args:
pattern: Optional pattern to match cache keys
pattern: Optional substring to match cache keys; only matching
entries are deleted.
"""
if self.cache_manager:
if hasattr(self.cache_manager, 'clear'):
if pattern:
# Clear only keys matching pattern
keys = self.cache_manager.keys()
for key in keys:
if pattern in key:
self.cache_manager.delete(key)
else:
self.cache_manager.clear()
if not self.cache_manager:
return
if pattern:
if (hasattr(self.cache_manager, 'list_cache_files')
and hasattr(self.cache_manager, 'delete')):
for entry in self.cache_manager.list_cache_files():
key = entry.get('key') if isinstance(entry, dict) else None
if key and pattern in key:
self.cache_manager.delete(key)
else:
self.logger.debug(
"Cache manager lacks list_cache_files/delete; "
"cannot clear by pattern")
elif hasattr(self.cache_manager, 'clear_cache'):
self.cache_manager.clear_cache()
elif hasattr(self.cache_manager, 'clear'):
self.cache_manager.clear()
else:
self.logger.debug("Cache manager exposes no clear method; no-op")
def _get_from_cache(self, key: str) -> Optional[Any]:
"""Get data from cache."""
+8 -4
View File
@@ -5,6 +5,7 @@ Handles configuration management and validation for LED matrix plugins.
Extracted from LEDMatrix core to provide reusable functionality for plugins.
"""
import copy
import json
import logging
from pathlib import Path
@@ -160,17 +161,20 @@ class ConfigHelper:
override_config: Configuration to merge in (takes precedence)
Returns:
Merged configuration dictionary
Merged configuration dictionary (fully independent of both
inputs — a shallow copy would alias un-overridden nested dicts,
so mutating the result would mutate the caller's base config).
"""
merged = base_config.copy()
merged = copy.deepcopy(base_config)
for key, value in override_config.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
# Recursively merge nested dictionaries
merged[key] = self.merge_configs(merged[key], value)
else:
# Override with new value
merged[key] = value
# Override with new value — deep-copied so mutating the
# merged result can't reach back into override_config.
merged[key] = copy.deepcopy(value)
return merged
+18 -29
View File
@@ -115,17 +115,13 @@ class DisplayHelper:
if home_logo and away_logo:
self._draw_logos(main_img, home_logo, away_logo)
# Draw status/period text (top center)
if status_text or period_text:
status_display = f"{period_text} {status_text}".strip()
if status_display:
self._draw_centered_text(draw, status_display,
fonts.get('time', fonts.get('status')),
y_position=1)
# Draw clock if available
if clock:
self._draw_centered_text(draw, clock, fonts.get('time'), y_position=1)
# Draw one combined top line (period/status/clock all share y=1 —
# drawing them separately overprinted each other).
top_line = " ".join(p for p in [period_text, status_text, clock] if p)
if top_line:
self._draw_centered_text(draw, top_line,
fonts.get('time', fonts.get('status')),
y_position=1)
# Draw scores (center)
score_text = f"{away_score}-{home_score}"
@@ -153,12 +149,18 @@ class DisplayHelper:
"""
Draw a ticker/scrolling text layout.
Renders a single static frame with the text at the left edge; the
caller advances the scroll by re-rendering or shifting. The
scroll_speed parameter is accepted for API compatibility but does
not affect this frame. (Previously the text was drawn starting at
x=display_width — entirely off-canvas — so every frame was blank.)
Args:
text: Text to display
font: Font to use
background_color: Background color
text_color: Text color
scroll_speed: Pixels to scroll per frame
scroll_speed: Accepted for compatibility; unused per-frame
Returns:
PIL Image with ticker layout
@@ -166,11 +168,7 @@ class DisplayHelper:
img = self.create_base_image(background_color)
draw = ImageDraw.Draw(img)
# Start text off-screen to the right
x_position = self.display_width
# Draw text
self._draw_text_with_outline(draw, text, (x_position, self.display_height // 2 - 6),
self._draw_text_with_outline(draw, text, (0, self.display_height // 2 - 6),
font, fill=text_color)
return img
@@ -214,15 +212,9 @@ class DisplayHelper:
Returns:
PIL Image with error message
"""
img = self.create_base_image((50, 0, 0)) # Dark red background
# Use default font
# Dark red background, white text
font = ImageFont.load_default()
# Draw centered error message
self._draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
return img
return self.draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
def draw_no_data_message(self, message: str = "No Data") -> Image.Image:
"""
@@ -234,11 +226,8 @@ class DisplayHelper:
Returns:
PIL Image with no data message
"""
img = self.create_base_image((0, 0, 0))
font = ImageFont.load_default()
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
return img
return self.draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
def get_display_dimensions(self) -> Tuple[int, int]:
"""
+60 -8
View File
@@ -6,6 +6,8 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
"""
import logging
import os
import tempfile
from pathlib import Path
from typing import Dict, List, Optional, Union
@@ -19,6 +21,10 @@ from src.common.permission_utils import (
)
# Well above any real team logo; bounds what a remote URL can write to disk.
MAX_LOGO_BYTES = 10 * 1024 * 1024
class LogoHelper:
"""
Helper class for logo loading, caching, and resizing.
@@ -226,7 +232,10 @@ class LogoHelper:
return {
'cached_logos': len(self._logo_cache),
'cache_size_limit': self.cache_size,
'cache_usage_percent': (len(self._logo_cache) / self.cache_size) * 100
'cache_usage_percent': (
(len(self._logo_cache) / self.cache_size) * 100
if self.cache_size else 0
),
}
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
@@ -258,17 +267,60 @@ class LogoHelper:
self._cache_order.append(cache_key)
def _download_logo(self, url: str, file_path: Path) -> None:
"""Download logo from URL."""
"""Download logo from URL.
The response size is capped and the saved file is verified as a
decodable image before it is left on disk: a logo URL is remote
input, and without this an oversized or malformed response would
be cached for every later load_logo() call to trip over.
The body is streamed and counted as it arrives rather than read
through response.content, which buffers the whole thing first —
a server that omits Content-Length and never stops sending would
exhaust memory before any size check could run. Nothing lands at
file_path until the download completes and decodes, so a failed
download cannot leave a truncated logo behind either.
"""
# Ensure directory exists with proper permissions
ensure_directory_permissions(file_path.parent, get_assets_dir_mode())
# Download with timeout
response = self.session.get(url, timeout=30)
response.raise_for_status()
# A unique temp name, not a fixed "<name>.part": two plugins can
# ask for the same logo at once, and a shared name would let them
# interleave writes into one file, publish the mixture, or delete
# each other's partial. Same directory, so os.replace stays atomic.
fd, tmp_name = tempfile.mkstemp(
dir=str(file_path.parent), prefix=file_path.name + '.', suffix='.part')
tmp_path = Path(tmp_name)
try:
# fdopen outermost so the descriptor mkstemp handed back is
# always adopted and closed, including when the request itself
# raises — load_logo_with_download swallows that, so a leak
# here would accumulate quietly on a URL that keeps failing.
with os.fdopen(fd, 'wb') as f:
with self.session.get(url, timeout=30, stream=True) as response:
response.raise_for_status()
downloaded = 0
for chunk in response.iter_content(chunk_size=64 * 1024):
if not chunk:
continue
downloaded += len(chunk)
if downloaded > MAX_LOGO_BYTES:
raise ValueError(
f"Logo at {url} exceeds the "
f"{MAX_LOGO_BYTES}-byte limit; not saved")
f.write(chunk)
# Save to file
with open(file_path, 'wb') as f:
f.write(response.content)
# Verify it decodes before it becomes the cached logo. PIL
# raises DecompressionBombError past its own pixel limit; a
# partial or non-image response raises UnidentifiedImageError
# (an OSError subclass).
with Image.open(tmp_path) as probe:
probe.load()
os.replace(tmp_path, file_path)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
# Set proper file permissions after saving
ensure_file_permissions(file_path, get_assets_file_mode())
+82 -35
View File
@@ -37,6 +37,13 @@ _RAW_MAGIC = b'SYNC_RAW'
_RAW_HEADER = struct.Struct('<HH') # width, height (uint16 LE)
# Upper bound on a decoded frame/scroll image. Generous for any real scroll
# image (a leader's full cycle is long but only panel-height tall), and low
# enough that a crafted image from any host on the LAN cannot force a large
# allocation on the render thread. Applied on both receive paths — the TCP
# image server and the follower's legacy-PNG UDP fallback.
_MAX_FRAME_W, _MAX_FRAME_H = 100_000, 256
SYNC_PORT = 5765
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
@@ -101,6 +108,7 @@ class DisplaySyncManager:
self._peer_chain: int = 0
self._last_heartbeat_time: float = 0.0
self._leader_width: int = 0 # set by display_controller after init
self._oversized_frame_warned: bool = False
# Follower state
self._follower_state = FollowerState.STANDALONE
@@ -174,6 +182,10 @@ class DisplaySyncManager:
continue
except Exception as exc:
self.logger.debug("Sync leader recv error: %s", exc)
# Brief backoff: a socket left in a bad state raises
# immediately, which would otherwise spin this thread at
# 100% CPU logging the same error.
time.sleep(0.1)
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
hw = self._hw_config
@@ -273,11 +285,10 @@ class DisplaySyncManager:
break
data.extend(chunk)
img = Image.open(io.BytesIO(data))
_MAX_W, _MAX_H = 100_000, 256 # generous for any real scroll image
if img.width > _MAX_W or img.height > _MAX_H:
if img.width > _MAX_FRAME_W or img.height > _MAX_FRAME_H:
self.logger.warning(
"Sync: rejected oversized scroll image %dx%d (max %dx%d) from %s",
img.width, img.height, _MAX_W, _MAX_H, addr,
img.width, img.height, _MAX_FRAME_W, _MAX_FRAME_H, addr,
)
continue
try:
@@ -396,7 +407,7 @@ class DisplaySyncManager:
data = header + arr.tobytes()
if len(data) <= 65000:
self._send_sock.sendto(data, (self._peer_ip, self.port))
elif not getattr(self, '_oversized_frame_warned', False):
elif not self._oversized_frame_warned:
self._oversized_frame_warned = True
self.logger.warning(
"Sync: frame too large for UDP (%d bytes, max 65000) — "
@@ -451,43 +462,76 @@ class DisplaySyncManager:
)
self.write_status_file()
def _handle_received_frame(self, img: Image.Image, sender_ip: str) -> None:
"""Record a decoded leader frame and enter follower mode if needed."""
with self._frame_lock:
self._latest_frame = img
self._last_leader_frame_time = time.time()
self._leader_ip = sender_ip
if self._follower_state == FollowerState.STANDALONE:
self._follower_state = FollowerState.FOLLOWER
self.logger.info(
"Sync: leader active at %s — switching to follower mode",
sender_ip,
)
self.write_status_file()
def _follower_recv_loop(self) -> None:
while self._running:
try:
data, addr = self._recv_sock.recvfrom(65535)
sender_ip = addr[0]
if data[:8] == _RAW_MAGIC or len(data) > 512:
# Frame data: prefer magic-tagged raw RGB; fall back to legacy PNG
if data[:8] == _RAW_MAGIC:
# Magic-tagged raw RGB frame — self-describing, no guessing.
try:
if data[:8] == _RAW_MAGIC:
w, h = _RAW_HEADER.unpack(data[8:12])
raw = data[12:]
img = Image.frombuffer(
"RGB", (w, h), raw, "raw", "RGB", 0, 1
)
else:
# Fallback: try legacy PNG
img = Image.open(io.BytesIO(data))
img.load()
with self._frame_lock:
self._latest_frame = img
self._last_leader_frame_time = time.time()
self._leader_ip = sender_ip
if self._follower_state == FollowerState.STANDALONE:
self._follower_state = FollowerState.FOLLOWER
self.logger.info(
"Sync: leader active at %s — switching to follower mode",
sender_ip,
)
self.write_status_file()
w, h = _RAW_HEADER.unpack(data[8:12])
raw = data[12:]
img = Image.frombuffer(
"RGB", (w, h), raw, "raw", "RGB", 0, 1
)
self._handle_received_frame(img, sender_ip)
except Exception as exc:
self.logger.debug("Sync: frame decode error: %s", exc)
else:
# Control message
# No magic prefix. Whether the payload parses as JSON
# decides between a control message and a legacy
# (pre-magic) PNG frame — both wire formats are
# self-describing, so no size heuristic is needed. A
# >512-byte control message used to be misrouted into
# image decode and silently dropped.
try:
msg = json.loads(data.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
# Not JSON — try a legacy PNG frame.
try:
img = Image.open(io.BytesIO(data))
if img.width > _MAX_FRAME_W or img.height > _MAX_FRAME_H:
# Same cap the TCP image path applies: decode
# is deferred until load(), so check first.
self.logger.debug(
"Sync: rejected oversized legacy frame %dx%d from %s",
img.width, img.height, sender_ip,
)
continue
img.load()
self._handle_received_frame(img, sender_ip)
except Exception as exc:
self.logger.debug("Sync: frame decode error: %s", exc)
continue
# It parsed, so it is a control message and never a
# frame. Read and validate its fields under a guard —
# a UDP payload is attacker-shaped, so a non-object
# body makes .get() raise AttributeError and an "sx"
# carrying a non-numeric x raises ValueError/TypeError
# — but dispatch the callback *outside* it. Running
# the callback in here would let a fault in someone
# else's code read as a malformed packet and be
# logged as one.
fire_new_cycle = False
try:
t = msg.get("t")
if t == "hello_ack":
self._leader_ip = sender_ip
@@ -511,19 +555,22 @@ class DisplaySyncManager:
sender_ip,
)
self.write_status_file()
if self._on_new_cycle:
self._on_new_cycle() # build initial scroll image
fire_new_cycle = True # build initial scroll image
elif t == "nc":
# Leader started a new scroll cycle — rebuild local image
if self._on_new_cycle:
self._on_new_cycle()
except (json.JSONDecodeError, UnicodeDecodeError, KeyError):
pass
fire_new_cycle = True
except (KeyError, AttributeError, TypeError, ValueError) as exc:
self.logger.debug("Sync: malformed control message: %s", exc)
continue
if fire_new_cycle and self._on_new_cycle:
self._on_new_cycle()
except socket.timeout:
continue
except Exception as exc:
self.logger.debug("Sync follower recv error: %s", exc)
time.sleep(0.1)
def _follower_announce_loop(self) -> None:
hw = self._hw_config
+64 -9
View File
@@ -269,20 +269,47 @@ class ConfigManager:
self.logger.error(error_msg, exc_info=True)
raise ConfigError(error_msg, config_path=self.config_path) from e
@staticmethod
def _is_parallel_secrets_list(value: Any) -> bool:
"""True for the parallel-placeholder list shape emitted by
``secret_helpers.separate_secrets`` for array-item secrets: a
non-empty list whose elements are ALL dicts (``{}`` marks an item
with no secrets). Any other list-shaped secrets value is a
whole-key secret (e.g. a list of secret scalars)."""
return (isinstance(value, list) and bool(value)
and all(isinstance(item, dict) for item in value))
def _strip_secrets_recursive(self, data_to_filter: Dict[str, Any], secrets: Dict[str, Any]) -> Dict[str, Any]:
"""Recursively remove secret keys from a dictionary."""
result = {}
for key, value in data_to_filter.items():
if key in secrets:
if isinstance(value, dict) and isinstance(secrets[key], dict):
# This key is a shared group, recurse
stripped_sub_dict = self._strip_secrets_recursive(value, secrets[key])
if stripped_sub_dict: # Only add if there's non-secret data left
result[key] = stripped_sub_dict
# Else, it's a secret key at this level, so we skip it
else:
if key not in secrets:
# This key is not in secrets, so we keep it
result[key] = value
continue
sec = secrets[key]
if isinstance(value, dict) and isinstance(sec, dict):
# This key is a shared group, recurse
stripped_sub_dict = self._strip_secrets_recursive(value, sec)
if stripped_sub_dict: # Only add if there's non-secret data left
result[key] = stripped_sub_dict
elif isinstance(value, list) and self._is_parallel_secrets_list(sec):
# Parallel-list shape from separate_secrets: sec[i] holds the
# secret fields of value[i] ({} = item i has none). Strip each
# item and ALWAYS keep the list — indices must survive so the
# merge-on-load can realign secrets with their items. The
# regular list's length is authoritative: extra secrets
# entries are ignored.
stripped_items = []
for i, item in enumerate(value):
s_item = sec[i] if i < len(sec) else {}
if isinstance(item, dict) and s_item:
stripped_items.append(self._strip_secrets_recursive(item, s_item))
else:
stripped_items.append(item)
result[key] = stripped_items
# Else: whole-key secret (scalar, list of secret scalars, or a
# shape mismatch) -> drop the key entirely. Never leak.
return result
def _load_secrets_for_save(self) -> Dict[str, Any]:
@@ -358,11 +385,39 @@ class ConfigManager:
return None
def _deep_merge(self, target: Dict[str, Any], source: Dict[str, Any]) -> None:
"""Deep merge source dict into target dict."""
"""Deep merge source dict into target dict.
Sole call site: merging config_secrets.json into the loaded config.
Understands the parallel-list shape separate_secrets emits for
array-item secrets (see _is_parallel_secrets_list): each secrets
list item is merged into the config list item at the same index
({} placeholders skipped). The config list's length is
authoritative — a user deleting an array item from config.json
must not have it resurrected from a stale secrets entry."""
for key, value in source.items():
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
self._deep_merge(target[key], value)
elif (key in target and isinstance(target[key], list)
and self._is_parallel_secrets_list(value)):
tlist = target[key]
for i, s_item in enumerate(value):
if i >= len(tlist):
# Interpolate only config-side data here — nothing
# iterated out of the secrets dict (not even the key
# name) may reach the log.
self.logger.warning(
"A secrets list is longer than the config list it "
"parallels (config has %d item(s)); ignoring the "
"extra entries", len(tlist))
break
if not s_item:
continue # {} placeholder: item i has no secrets
if isinstance(tlist[i], dict):
self._deep_merge(tlist[i], s_item)
else:
tlist[i] = s_item # shape drift; the secret wins
else:
# Scalars AND whole-secret scalar arrays: replace (legacy).
target[key] = value
def _create_config_from_template(self) -> None:
+62 -6
View File
@@ -44,6 +44,20 @@ from src.common.sync_manager import DisplaySyncManager, SyncRole
# Get logger with consistent configuration
logger = get_logger(__name__)
# How long startup will wait for plugins to fetch their first data before
# showing anything. Each plugin's update blocks for up to the executor's 30s
# timeout and they run one after another, so the uncapped total is the sum of
# every slow plugin: 82 seconds on the worst boot measured, with a blank panel
# throughout. Whatever does not finish in time is picked up by the scheduled
# update tick moments later, with the display already running.
_INITIAL_UPDATE_BUDGET_SECONDS = 20.0
# The least budget worth starting a plugin with. Below this the plugin is
# deferred instead: granting it a floor would let the pass run past its
# deadline, and granting it the true remainder would record a timeout for a
# slot it never had a chance to use.
_MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.0
# Vegas mode import (lazy loaded to avoid circular imports)
_vegas_mode_imported = False
VegasModeCoordinator = None
@@ -90,7 +104,8 @@ class DisplayController:
# Validate startup configuration
try:
from src.startup_validator import StartupValidator
validator = StartupValidator(self.config_manager)
validator = StartupValidator(self.config_manager,
cache_manager=self.cache_manager)
is_valid, errors, warnings = validator.validate_all()
if warnings:
@@ -258,7 +273,8 @@ class DisplayController:
# Validate plugins after plugin manager is created
try:
from src.startup_validator import StartupValidator
validator = StartupValidator(self.config_manager, self.plugin_manager)
validator = StartupValidator(self.config_manager, self.plugin_manager,
cache_manager=self.cache_manager)
is_valid, errors, warnings = validator.validate_all()
if warnings:
@@ -461,7 +477,7 @@ class DisplayController:
# Initial data update for plugins (ensures data available on first display)
logger.info("Performing initial plugin data update...")
update_start = time.time()
self._update_modules()
self._update_modules(deadline=update_start + _INITIAL_UPDATE_BUDGET_SECONDS)
logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start)
# Initialize Vegas mode coordinator
@@ -817,14 +833,42 @@ class DisplayController:
self._cached_target_brightness = normal_brightness # persist for minute-gate
return normal_brightness
def _update_modules(self):
"""Update all plugin modules."""
def _update_modules(self, deadline: Optional[float] = None):
"""Update all plugin modules.
Args:
deadline: Wall-clock time after which remaining plugins are left
for the scheduled update tick instead of being waited on. Each
update blocks this thread for up to the executor's timeout, and
they run one after another, so without a bound the total is the
sum of every slow plugin on the system. Measured at startup on
a live rig: 82 seconds, 55 and 26 on the two boots before -- all
of it with nothing on the panel.
"""
if not self.plugin_manager:
return
# Update all loaded plugins
plugins_dict = getattr(self.plugin_manager, 'loaded_plugins', None) or getattr(self.plugin_manager, 'plugins', {})
deferred = []
for plugin_id, plugin_instance in plugins_dict.items():
update_timeout = None
if deadline is not None:
update_timeout = deadline - time.time()
if update_timeout < _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS:
# Too little left to be worth starting. Deferring rather
# than granting a floor keeps the budget a real ceiling --
# clamping up to a minimum let a plugin that began with a
# sliver left run on past the deadline -- and a plugin
# handed a slot it cannot use would just be recorded as
# having timed out.
#
# Nothing is lost either way: a plugin that has never
# updated is immediately due, so run_scheduled_updates()
# picks it up within seconds, with the display already
# running.
deferred.append(plugin_id)
continue
# Check circuit breaker before attempting update
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
if self.plugin_manager.health_tracker.should_skip_plugin(plugin_id):
@@ -833,7 +877,13 @@ class DisplayController:
# Use PluginExecutor if available for safe execution
if hasattr(self.plugin_manager, 'plugin_executor'):
success = self.plugin_manager.plugin_executor.execute_update(plugin_instance, plugin_id)
# The remaining budget is the timeout, so the pass cannot
# run past its deadline. Bounding the loop alone did not do
# it: the last plugin to start could still block for the
# executor's full 30s, which turned a 20s budget into a 31.8s
# pass on the rig.
success = self.plugin_manager.plugin_executor.execute_update(
plugin_instance, plugin_id, timeout=update_timeout)
if success and hasattr(self.plugin_manager, 'plugin_last_update'):
self.plugin_manager.plugin_last_update[plugin_id] = time.time()
else:
@@ -852,6 +902,12 @@ class DisplayController:
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
self.plugin_manager.health_tracker.record_failure(plugin_id, exc)
if deferred:
logger.info(
"Initial update budget spent; %d plugin(s) left to the update "
"tick so the display can start: %s",
len(deferred), ", ".join(deferred))
def _tick_plugin_updates_for_vegas(self) -> None:
"""Run scheduled plugin updates and tell Vegas mode which plugins
actually got fresh data, so it can hot-swap them into the scroll
+112 -3
View File
@@ -25,6 +25,7 @@ the same object.
import json
import os
import socket
import tempfile
if os.getenv("EMULATOR", "false") == "true":
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
@@ -258,6 +259,26 @@ class DisplayManager:
# Initialize managers
# Calendar manager is now initialized by DisplayController
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
# "normal" needs no suffix since 0 degrees is the identity transform.
_ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
"""Compose the raw pixel_mapper_config string with the orientation setting.
`pixel_mapper_config` stays available as a free-form advanced field (e.g.
for "U-mapper" chain layouts); `orientation` is the user-facing dropdown
for physical mounting (e.g. panels mounted upside down) and is appended as
a "Rotate:<deg>" mapper rather than overwriting any existing config.
"""
base_mapper = (hardware_config.get('pixel_mapper_config') or '').strip()
orientation = hardware_config.get('orientation', 'normal')
degrees = self._ORIENTATION_ROTATE_DEGREES.get(orientation)
if degrees is None:
return base_mapper
rotate_mapper = f'Rotate:{degrees}'
return f'{base_mapper};{rotate_mapper}' if base_mapper else rotate_mapper
def _setup_matrix(self):
"""Initialize the RGB matrix with configuration settings."""
_init_error_str = None
@@ -283,7 +304,7 @@ class DisplayManager:
options.pwm_bits = hardware_config.get('pwm_bits', 10)
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
options.pixel_mapper_config = hardware_config.get('pixel_mapper_config', '')
options.pixel_mapper_config = self._build_pixel_mapper_config(hardware_config)
options.row_address_type = hardware_config.get('row_address_type', 0)
options.multiplexing = hardware_config.get('multiplexing', 0)
options.panel_type = hardware_config.get('panel_type', '')
@@ -497,6 +518,91 @@ class DisplayManager:
logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
return -1
@staticmethod
def _local_ip() -> Optional[str]:
"""This device's address on the network it routes through, or None.
Deliberately not `hostname -I` or a systemctl probe for AP mode, which
is how the web launcher does it: both spawn processes with multi-second
timeouts, and this runs on the startup path the rest of this change
exists to shorten. Connecting a UDP socket sends no packets -- it only
asks the kernel which source address it would use -- so it costs
microseconds and works with the network down, as long as a route
exists.
"""
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(0.2)
sock.connect(("8.8.8.8", 80)) # nosec B104 - no traffic; selects a route
ip = sock.getsockname()[0]
return ip if ip and not ip.startswith("127.") else None
except OSError:
return None
finally:
if sock is not None:
try:
sock.close()
except OSError:
pass
def _fitting_font(self, lines, width):
"""The largest font from the usual ladder that fits every line."""
candidates = [self.font,
("assets/fonts/4x6-font.ttf", 6)]
for candidate in candidates:
try:
font = candidate
if isinstance(candidate, tuple):
font = ImageFont.truetype(candidate[0], candidate[1])
if all(self.draw.textlength(t, font=font) <= width for t in lines):
return font
except (OSError, ValueError, AttributeError):
continue
return self.font
def _draw_startup_banner(self, lines, width: int, height: int) -> None:
"""Centre `lines` over whatever the test pattern already drew.
This screen stays on the panel for the whole initial plugin update, and
on a headless Pi it is the only place the device's address appears
without going looking for it -- so it has to be readable off a wall,
not merely present.
The font is chosen to fit rather than fixed at 8px: "Initializing" is
96px in PressStart2P, which ran off the side of a 64px panel even
before an address was added. And the pattern is punched out behind the
text, because the diagonal runs through the middle of the panel, which
is exactly where this sits.
The text stays blue. It is not decoration: the pattern draws one pure
channel per element -- red border, green diagonal, blue text -- so that
a glance at the panel says whether led_rgb_sequence is right. Swap the
wiring to BGR and the border comes up blue and this text red. Drawing
it white would light all three channels and destroy the only blue
reference on the screen, which is why it is worth a comment rather
than a quiet preference.
"""
if not lines:
return
font = self._fitting_font(lines, width - 2)
line_height = self.draw.textbbox((0, 0), "Ag", font=font)[3] + 1
block_height = line_height * len(lines)
block_top = max(1, (height - block_height) // 2)
block_width = max(self.draw.textlength(t, font=font) for t in lines)
block_left = max(0, (width - block_width) // 2)
self.draw.rectangle(
[block_left - 2, block_top - 1,
block_left + block_width + 1, block_top + block_height],
fill=(0, 0, 0))
for row, line in enumerate(lines):
line_width = self.draw.textlength(line, font=font)
self.draw.text(
(max(0, (width - line_width) // 2), block_top + row * line_height),
line, font=font, fill=(0, 0, 255))
def _draw_test_pattern(self):
"""Draw a test pattern to verify the display is working."""
try:
@@ -516,8 +622,11 @@ class DisplayManager:
# Draw a diagonal line
self.draw.line([0, 0, self.matrix.width-1, self.matrix.height-1], fill=(0, 255, 0))
# Draw some text - changed from "TEST" to "Initializing" with smaller font
self.draw.text((10, 10), "Initializing", font=self.font, fill=(0, 0, 255))
lines = ["Initializing"]
ip = self._local_ip()
if ip:
lines.append(ip)
self._draw_startup_banner(lines, self.matrix.width, self.matrix.height)
# Update the display once after everything is drawn
self.update_display()
+12 -6
View File
@@ -168,9 +168,13 @@ class DynamicTeamResolver:
# Sort by ranking (1, 2, 3, etc.)
sorted_rankings = dict(sorted(rankings.items(), key=lambda x: x[1]))
# Cache the results
self._rankings_cache = sorted_rankings
self._cache_timestamp = current_time
# Cache the results ON THE CLASS. Assigning through self
# would create instance attributes that shadow the shared
# class-level cache, making it per-instance — and every
# scoreboard constructs its own resolver, so the cache
# would never actually be shared.
DynamicTeamResolver._rankings_cache = sorted_rankings
DynamicTeamResolver._cache_timestamp = current_time
self.logger.info(f"Fetched rankings for {len(sorted_rankings)} teams")
return sorted_rankings
@@ -216,9 +220,11 @@ class DynamicTeamResolver:
return any(pattern in team_name.upper() for pattern in dynamic_patterns)
def clear_cache(self):
"""Clear the rankings cache to force fresh data on next request."""
self._rankings_cache = {}
self._cache_timestamp = 0
"""Clear the SHARED rankings cache to force fresh data on next
request. Writes through the class assigning via self would only
shadow the shared cache for this instance."""
DynamicTeamResolver._rankings_cache = {}
DynamicTeamResolver._cache_timestamp = 0
self.logger.info("Cleared dynamic team rankings cache")
+13 -4
View File
@@ -5,6 +5,7 @@ Provides consistent logging configuration across the LEDMatrix application.
Supports structured logging with context information and appropriate log levels.
"""
import copy
import logging
import sys
import os
@@ -65,8 +66,12 @@ class ContextualFormatter(logging.Formatter):
self.include_context = include_context
def format(self, record: logging.LogRecord) -> str:
"""Format log record with context."""
# Add context to message if present
"""Format log record with context.
Works on a shallow copy of the record: a record is formatted once
PER HANDLER, so mutating record.msg in place (the old behavior)
prepended the context prefix again for every additional handler.
"""
if self.include_context:
context_parts = []
@@ -81,6 +86,7 @@ class ContextualFormatter(logging.Formatter):
context_parts.append(f"[{key}: {value}]")
if context_parts:
record = copy.copy(record)
record.msg = ' '.join(context_parts) + ' ' + str(record.msg)
return super().format(record)
@@ -224,8 +230,11 @@ def log_warning(logger: logging.Logger, message: str, **kwargs) -> None:
def log_error(logger: logging.Logger, message: str, **kwargs) -> None:
"""Log error message with context."""
log_with_context(logger, logging.ERROR, message, **kwargs, exc_info=True)
"""Log error message with context. Defaults exc_info=True; a caller
passing exc_info explicitly wins (the old hardcoded keyword raised
TypeError on that duplicate)."""
kwargs.setdefault('exc_info', True)
log_with_context(logger, logging.ERROR, message, **kwargs)
def log_debug(logger: logging.Logger, message: str, **kwargs) -> None:
+11 -6
View File
@@ -364,8 +364,10 @@ class BasePlugin(ABC):
# Handle None case
if duration is None:
pass # Fall through to config
# Try to convert to float if it's a number or numeric string
elif isinstance(duration, (int, float)):
# Try to convert to float if it's a number or numeric string.
# bool is excluded: it's an int subclass, and True would
# otherwise read as a 1-second duration.
elif isinstance(duration, (int, float)) and not isinstance(duration, bool):
if duration > 0:
return float(duration)
else:
@@ -403,8 +405,9 @@ class BasePlugin(ABC):
# Fall back to config
config_duration = self.config.get("display_duration", 15.0)
try:
# Ensure config value is also a valid float
if isinstance(config_duration, (int, float)):
# Ensure config value is also a valid float (bool excluded — an
# int subclass that would otherwise read True as 1 second)
if isinstance(config_duration, (int, float)) and not isinstance(config_duration, bool):
if config_duration > 0:
return float(config_duration)
else:
@@ -794,10 +797,12 @@ class BasePlugin(ABC):
self.logger.error("'enabled' must be a boolean")
return False
# Check display_duration if present
# Check display_duration if present. bool is excluded explicitly:
# it's an int subclass, and get_display_duration rejects it too.
if "display_duration" in self.config:
duration = self.config["display_duration"]
if not isinstance(duration, (int, float)) or duration <= 0:
if (not isinstance(duration, (int, float))
or isinstance(duration, bool) or duration <= 0):
self.logger.error("'display_duration' must be a positive number")
return False
+46 -12
View File
@@ -6,6 +6,7 @@ Manages saved GitHub repository URLs for easy plugin discovery and installation.
import json
import logging
import os
from pathlib import Path
from typing import List, Dict, Optional
@@ -43,20 +44,45 @@ class SavedRepositoriesManager:
return []
def _save_repositories(self) -> bool:
"""Save repositories to file."""
"""Save repositories to file atomically.
Writes to a temp file in the same directory and os.replace()s it
over the target, so a failed write can never truncate or
half-overwrite an existing saved_repositories.json.
"""
tmp_path = self.config_path.with_suffix(self.config_path.suffix + '.tmp')
try:
# Ensure directory exists
self.config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.config_path, 'w') as f:
with open(tmp_path, 'w') as f:
json.dump(self.repositories, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, self.config_path)
self.logger.info(f"Saved {len(self.repositories)} repositories to {self.config_path}")
return True
except Exception as e:
self.logger.error(f"Error saving repositories: {e}")
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return False
@staticmethod
def _clean_url(repo_url: str) -> str:
"""Normalize a repo URL: strip whitespace, trailing slashes, and a
trailing ``.git`` suffix ONLY. (The old ``.replace('.git', '')``
was an unanchored substring replace that mangled URLs merely
containing ``.git``, e.g. ``https://github.com/user/my.github.io``.)
"""
repo_url = repo_url.strip().rstrip('/')
if repo_url.endswith('.git'):
repo_url = repo_url[:-4]
return repo_url
def get_all(self) -> List[Dict[str, str]]:
"""Get all saved repositories."""
return self.repositories.copy()
@@ -72,8 +98,7 @@ class SavedRepositoriesManager:
Returns:
True if added successfully
"""
# Clean URL
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
repo_url = self._clean_url(repo_url)
# Check if already exists
for repo in self.repositories:
@@ -96,7 +121,12 @@ class SavedRepositoriesManager:
'type': 'registry' if 'plugins.json' in repo_url or 'ledmatrix-plugins' in repo_url.lower() else 'single'
})
return self._save_repositories()
if not self._save_repositories():
# Keep memory consistent with disk: a failed save must not leave
# a phantom entry that only this process can see.
self.repositories.pop()
return False
return True
def remove(self, repo_url: str) -> bool:
"""
@@ -108,21 +138,25 @@ class SavedRepositoriesManager:
Returns:
True if removed successfully
"""
# Clean URL
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
repo_url = self._clean_url(repo_url)
original_count = len(self.repositories)
self.repositories = [r for r in self.repositories if r.get('url') != repo_url]
previous = self.repositories
remaining = [r for r in previous if r.get('url') != repo_url]
if len(self.repositories) < original_count:
return self._save_repositories()
if len(remaining) < len(previous):
self.repositories = remaining
if not self._save_repositories():
# Failed save: restore so memory matches disk.
self.repositories = previous
return False
return True
else:
self.logger.warning(f"Repository not found: {repo_url}")
return False
def has(self, repo_url: str) -> bool:
"""Check if a repository is already saved."""
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
repo_url = self._clean_url(repo_url)
return any(r.get('url') == repo_url for r in self.repositories)
def get_registry_repositories(self) -> List[Dict[str, str]]:
+55 -6
View File
@@ -73,6 +73,11 @@ class RenderResult:
golden_ok: Optional[bool] = None
golden_diff_pixels: int = 0
golden_max_delta: int = 0
# what display() handed back; the controller skips a mode only on False
display_returned: Any = None
# empty-frame check: rendered nothing while not reporting "no content"
empty_claimed: Optional[bool] = None # True when that happened
empty_ok: Optional[bool] = None # False only in strict mode
# fill / scale-up check (populated only for sizes >= 2x the design size)
fill_checked: bool = False
fill_ok: Optional[bool] = None # False only in strict mode
@@ -92,6 +97,8 @@ class RenderResult:
return False
if self.fill_ok is False:
return False
if self.empty_ok is False:
return False
return True
@@ -132,21 +139,25 @@ def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
return plugin_instance
def _render_mode(plugin_instance: Any, mode: str) -> None:
def _render_mode(plugin_instance: Any, mode: str) -> Any:
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
drive the plugin's internal mode state machine (first display() call renders
modes[current_mode_index] when current_display_mode is None)."""
modes[current_mode_index] when current_display_mode is None).
Returns whatever display() returned. The display controller skips a mode
whose display() returns False, so that value decides whether an empty mode
is rotated past or sat on -- which makes it worth reporting rather than
discarding."""
sig = inspect.signature(plugin_instance.display)
if "display_mode" in sig.parameters:
plugin_instance.display(force_clear=True, display_mode=mode)
return
return plugin_instance.display(force_clear=True, display_mode=mode)
modes = getattr(plugin_instance, "modes", None)
if modes and mode in modes:
plugin_instance.current_mode_index = list(modes).index(mode)
if hasattr(plugin_instance, "current_display_mode"):
plugin_instance.current_display_mode = None
plugin_instance.display(force_clear=False)
return plugin_instance.display(force_clear=False)
def _freeze(freeze_time: Optional[str]):
@@ -234,7 +245,7 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
plugin_id, mode, e)
if result.error is None:
_render_mode(inst, mode)
result.display_returned = _render_mode(inst, mode)
result.image = dm.get_image()
result.overflow = dm.check_overflow()
except Exception as e: # noqa: BLE001 — a display crash is a real failure
@@ -341,6 +352,44 @@ def fill_metrics(image: Image.Image) -> Tuple[float, float, float]:
return (extent_x, extent_y, ink)
def check_empty_claimed(results: List[RenderResult],
strict: bool = False) -> List[RenderResult]:
"""Flag a mode that rendered nothing without reporting "no content".
The display controller skips a mode whose ``display()`` returns False, and
treats anything else -- including None -- as "content was shown". A mode
that draws nothing and does not return False therefore holds whatever is on
the panel for its whole display duration. Since a mode switch clears first,
that is a blank screen. Two sports plugins shipped exactly this: their
``display()`` returned None on every path, so an out-of-season league sat
blank for its full duration rather than being rotated past.
Warn-only by default, because a blank frame is not automatically wrong: a
scroll mode whose first frame is its blank scroll-in buffer renders empty
and is behaving correctly. ``strict=True`` sets ``empty_claimed`` such that
``RenderResult.ok`` fails -- opt in per plugin via harness.json
``{"empty_check": "strict"}`` once its modes are known to draw on the
fixture data.
Note this can only catch what the fixtures actually render. A plugin whose
harness fixture seeds content never exercises its empty path here; the
source-level gate in the plugins repo covers that case.
"""
for r in results:
if r.image is None or r.error is not None:
continue
# An explicit False is the plugin correctly saying "nothing to show".
if r.display_returned is False:
continue
if r.image.convert("L").point(
lambda p: 255 if p > _LIT_THRESHOLD else 0).getbbox() is not None:
continue
r.empty_claimed = True
if strict:
r.empty_ok = False
return results
def check_scale_up(results: List[RenderResult],
design_size: Tuple[int, int] = (128, 32),
min_extent: float = _MIN_FILL_EXTENT,
+28 -4
View File
@@ -15,16 +15,23 @@ from src.logging_config import get_logger
class StartupValidator:
"""Validates system state on startup."""
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None) -> None:
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None,
cache_manager: Optional[Any] = None) -> None:
"""
Initialize the startup validator.
Args:
config_manager: ConfigManager instance
plugin_manager: Optional PluginManager instance
cache_manager: The CacheManager the application will actually use.
Pass it. Without one this validator builds its own just to read
a directory path, which reports on a cache the app does not
use and leaves behind a cleanup thread that nothing stops --
validation runs twice per startup, so that was two of them.
"""
self.config_manager = config_manager
self.plugin_manager = plugin_manager
self.cache_manager = cache_manager
self.logger = get_logger(__name__)
self.errors: List[str] = []
self.warnings: List[str] = []
@@ -38,6 +45,11 @@ class StartupValidator:
"""
self.logger.info("Starting startup validation...")
# Fresh lists each run — without this, calling validate_all() twice
# duplicated every message.
self.errors = []
self.warnings = []
# Validate configuration
self._validate_config()
@@ -86,9 +98,21 @@ class StartupValidator:
def _validate_cache_directory(self) -> None:
"""Validate cache directory permissions."""
try:
from src.cache_manager import CacheManager
cache_manager = CacheManager()
cache_dir = cache_manager.get_cache_dir()
cache_manager = self.cache_manager
if cache_manager is None:
# No caller supplied one (older embedders, direct use in a
# script). Build one, but do not leave its cleanup thread
# running behind us -- this instance is discarded on the next
# line but the thread is a closure over it, so it would never
# be collected.
from src.cache_manager import CacheManager
cache_manager = CacheManager()
try:
cache_dir = cache_manager.get_cache_dir()
finally:
cache_manager.stop_cleanup_thread()
else:
cache_dir = cache_manager.get_cache_dir()
if not cache_dir:
self.warnings.append("Cache directory not available - caching will be disabled")
+17 -5
View File
@@ -104,10 +104,22 @@ class VegasModeConfig:
overflow_mode: str = "rotate"
# Cap on one plugin's share of a cycle, as a multiple of display width.
# A single ticker returning 7,000px would otherwise hold the panel for over
# two minutes. Overflow is deferred to later cycles rather than discarded.
# 0 disables the cap.
max_plugin_width_ratio: float = 3.0
# 0 (the default) disables the cap, so every plugin contributes all of its
# content and is always entered at its beginning.
#
# Capping was the default until it proved to cost more than it bought.
# Measured over a 17-plugin fleet on a 512px panel, only four plugins were
# ever wide enough to hit a 3.0 cap; for those four it produced two visible
# faults. Content resumed mid-item on each appearance (a news ticker entered
# at column 6027 of its own strip), and the final window of a rotation was
# whatever happened to be left — 348px of a 1840px stocks ticker, seven
# seconds of panel time. Both read as the display being broken rather than
# as deferral working.
#
# A wide plugin does hold the panel for a long time uncapped: set the cap
# per plugin with vegas_max_width_screens where that matters, rather than
# globally where it mostly hurts plugins that were never the problem.
max_plugin_width_ratio: float = 0.0
# Plugin management
plugin_order: List[str] = field(default_factory=list)
@@ -159,7 +171,7 @@ class VegasModeConfig:
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
max_plugin_width_ratio=float(
vegas_config.get('max_plugin_width_ratio', 3.0)),
vegas_config.get('max_plugin_width_ratio', 0.0)),
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
plugin_order=list(vegas_config.get('plugin_order', [])),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
+33 -2
View File
@@ -12,6 +12,7 @@ Supports three display modes per plugin:
"""
import logging
import math
import time
import threading
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
@@ -30,6 +31,21 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _percentile(ordered: List[float], fraction: float) -> float:
"""Nearest-rank percentile of an already-sorted list.
Index ceil(n * fraction) - 1, so 100 samples at 0.99 give the 99th-ranked
value. The obvious int(n * fraction) is off by one and, at exactly 100
samples, lands on the maximum -- which is the number already reported
alongside this one as the worst frame, so the two columns would agree
precisely when the sample was smallest.
"""
if not ordered:
return 0.0
index = math.ceil(len(ordered) * fraction) - 1
return ordered[min(len(ordered) - 1, max(0, index))]
class VegasModeCoordinator:
"""
Orchestrates Vegas scroll mode operation.
@@ -382,6 +398,12 @@ class VegasModeCoordinator:
fps_log_interval = 5.0 # Log FPS every 5 seconds
last_fps_log_time = start_time
fps_frame_count = 0
# A mean hides stutter completely. At 120fps a five-second window is
# ~600 frames, so a 200ms freeze -- plainly visible on a marquee --
# moves the average from 120.0 to 115.4 and reads as healthy. What a
# viewer actually notices is the worst frame, so track that too.
frame_worst = 0.0
frame_times: List[float] = []
logger.info("Starting Vegas iteration for %.1fs", duration)
@@ -417,6 +439,11 @@ class VegasModeCoordinator:
frame_elapsed = time.time() - frame_started
time.sleep(max(0.0, frame_interval - frame_elapsed))
# Measured before the sleep: time spent working, not pacing.
if frame_elapsed > frame_worst:
frame_worst = frame_elapsed
frame_times.append(frame_elapsed)
# Increment frame count and check for interrupt periodically
frame_count += 1
fps_frame_count += 1
@@ -425,12 +452,16 @@ class VegasModeCoordinator:
current_time = time.time()
if current_time - last_fps_log_time >= fps_log_interval:
fps = fps_frame_count / (current_time - last_fps_log_time)
p99 = _percentile(sorted(frame_times), 0.99)
logger.info(
"Vegas FPS: %.1f (target: %d, frames: %d)",
fps, self.vegas_config.target_fps, fps_frame_count
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
fps, self.vegas_config.target_fps, fps_frame_count,
p99 * 1000.0, frame_worst * 1000.0
)
last_fps_log_time = current_time
fps_frame_count = 0
frame_worst = 0.0
frame_times.clear()
if (self._interrupt_check and
frame_count % self._interrupt_check_interval == 0):
+153 -20
View File
@@ -68,6 +68,21 @@ class PluginAdapter:
# always the same opening items.
self._item_offsets: dict = {}
# What the matching entry in _item_offsets is an offset *into*, as
# (kind, size). An offset only means anything against the content it
# was derived from, and there are three incompatible kinds:
#
# ('rows', n) index into a list of n images
# ('cuts', n) index into the n item boundaries of one image
# ('cols', w) pixel column in a w-wide image with no item boundaries
#
# Without this the offsets were reused across kinds — a plugin that
# returned one wide image on one fetch and several rows on the next had
# a pixel column of 1400 read back as a row index — and across content
# changes, where a column recorded against a 9,793px news strip pointed
# into unrelated headlines once the strip refreshed to 9,505px.
self._offset_shapes: dict = {}
logger.info(
"PluginAdapter initialized: display=%dx%d",
self.display_width, self.display_height
@@ -398,6 +413,88 @@ class PluginAdapter:
return 0
return int(self.display_width * ratio)
def _resume_offset(self, plugin_id: str, shape: Tuple[str, int]) -> int:
"""
The plugin's stored rotation offset, if it still applies.
An offset is only meaningful against content shaped the way it was
when the offset was recorded. When the shape has changed a different
number of rows, a re-rendered strip with different item boundaries
the stored value points somewhere arbitrary, so rotation restarts.
Args:
plugin_id: Plugin identifier
shape: (kind, size) describing what an offset would index into now
Returns:
The stored offset, or 0 when it no longer applies
"""
if self._offset_shapes.get(plugin_id) != shape:
if plugin_id in self._item_offsets:
logger.info(
"[%s] Content is %s now, was %s — restarting the rotation "
"rather than resuming at a position that no longer means "
"anything", plugin_id, shape,
self._offset_shapes.get(plugin_id))
self._item_offsets.pop(plugin_id, None)
self._offset_shapes[plugin_id] = shape
return 0
return self._item_offsets.get(plugin_id, 0)
def _record_offset(
self, plugin_id: str, offset: int, shape: Tuple[str, int]
) -> None:
"""Store where the next window should resume, with what it indexes."""
if offset:
self._item_offsets[plugin_id] = offset
self._offset_shapes[plugin_id] = shape
else:
# A wrapped-to-zero rotation is the same as no state at all, and
# keeping the key would report a window as active when the next
# pass starts from the top anyway.
self._item_offsets.pop(plugin_id, None)
self._offset_shapes.pop(plugin_id, None)
def _clear_offset(self, plugin_id: str) -> None:
"""Forget any rotation state for a plugin."""
self._item_offsets.pop(plugin_id, None)
self._offset_shapes.pop(plugin_id, None)
def _merge_trailing_runt(self, end: int, width: int, budget: int) -> int:
"""
Extend a window to the end of the content when what would be left over
is too small to be worth its own pass.
Windows were placed by walking forward from the last one, which makes
the final window whatever happens to remain. Measured on a live panel
that produced a 1,840px stocks ticker splitting 1,492 + 348 the
second pass showing seven seconds of content before cutting, which
reads as the display failing rather than as a rotation.
Absorbing the remainder overruns the budget by less than one window
floor, which is a better trade than a fragment: the budget is a guard
against one plugin holding the panel for minutes, not a hard limit.
Args:
end: Column the window would otherwise end at
width: Full content width
budget: Width budget being applied
Returns:
``end``, or ``width`` when the remainder is below the floor
"""
remainder = width - end
# Measured against the budget rather than the panel: snapping to item
# boundaries means an ordinary window already lands short of the budget
# (a 512px budget over 182px-pitch items yields 348px windows), so an
# absolute floor would merge windows that were never fragments. Half a
# budget separates "a short last pass" from "a sliver", and caps the
# overrun this can cause at 1.5 budgets.
floor = budget // 2
if 0 < remainder < floor:
return width
return end
def _apply_width_budget(
self, images: List[Image.Image], plugin_id: str,
plugin: Optional['BasePlugin'] = None
@@ -435,32 +532,48 @@ class PluginAdapter:
if not budget or total <= budget:
# Fits, so reset rotation — the whole segment is being shown.
self._item_offsets.pop(plugin_id, None)
self._clear_offset(plugin_id)
return images
if len(images) == 1:
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
shape = ('rows', len(images))
if mode == 'truncate':
# Ordered content: always show from the top. Deliberately does not
# advance the offset, so the same opening items appear every time
# rather than the viewer being shown the middle of a ranked list.
start = 0
else:
start = self._item_offsets.get(plugin_id, 0) % len(images)
start = self._resume_offset(plugin_id, shape) % len(images)
selected: List[Image.Image] = []
used = 0
consumed = 0
# Walk forward from the rotation offset, taking whole items only, so a
# cut never lands in the middle of one.
#
# A window may overrun the budget while it is still shorter than the
# runt floor, for the same reason _merge_trailing_runt exists on the
# single-image path: a pass far shorter than its neighbours reads as
# the display failing rather than as a rotation. Rows of 450, 450 and
# 100 against a 512px budget used to give the 100 a pass of its own --
# two seconds against nine. Wrapping does not prevent that, because it
# only helps when the row wrapped to actually fits.
floor = budget // 2
for step in range(len(images)):
img = images[(start + step) % len(images)]
cost = img.width
if selected:
cost += self._row_gap(selected[-1], img)
if selected and used + cost > budget:
break
# Keep the overrun bounded at the same 1.5 budgets the
# single-image path allows. A next row too wide to absorb
# leaves a short window standing -- better than a window of
# 1.9 budgets, and the same trade the always-take-the-first
# rule below already makes.
if used >= floor or used + cost > budget + floor:
break
selected.append(img)
used += cost
consumed += 1
@@ -472,7 +585,8 @@ class PluginAdapter:
plugin_id, budget, len(selected), len(images), used
)
else:
self._item_offsets[plugin_id] = (start + consumed) % len(images)
self._record_offset(
plugin_id, (start + consumed) % len(images), shape)
logger.info(
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
"from offset %d; remainder deferred to a later cycle",
@@ -490,16 +604,13 @@ class PluginAdapter:
The cut is snapped to the nearest blank column so it does not slice
through a glyph or logo and leave half a character at the panel edge.
"""
if mode == 'truncate':
# Always the start of the strip, so a ranked table is never entered
# from the middle.
offset = 0
else:
offset = self._item_offsets.get(plugin_id, 0)
if offset >= img.width:
offset = 0
Rotation is tracked as an index into the strip's item boundaries rather
than as a pixel column, because a ticker re-renders between fetches. A
column recorded against one render points at unrelated content in the
next as soon as anything ahead of it changes width a digit in a
price, a shorter headline. The Nth boundary stays the Nth boundary.
"""
# Cut only where the plugin left a real gap between items. Snapping to
# any blank column used to pick the single-column gaps between
# characters, splitting a word and orphaning its tail into the next
@@ -514,9 +625,17 @@ class PluginAdapter:
# budget exactly. The gap rule exists to protect discrete items
# (words, ticker entries); it would be wrong to let a solid image
# escape the cap in its name.
end = min(offset + budget, img.width)
#
# With no items to index, the offset here has to stay a column, so
# it is only reusable while the image keeps its width.
shape = ('cols', img.width)
offset = 0 if mode == 'truncate' else self._resume_offset(
plugin_id, shape)
end = self._merge_trailing_runt(
min(offset + budget, img.width), img.width, budget)
if mode != 'truncate':
self._item_offsets[plugin_id] = 0 if end >= img.width else end
self._record_offset(
plugin_id, 0 if end >= img.width else end, shape)
logger.info(
"[%s] Width budget %dpx: cropped continuous %dpx image to "
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
@@ -528,8 +647,15 @@ class PluginAdapter:
# Cut mid-gap so the content either side keeps some breathing room.
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
start = max((c for c in cuts if c <= offset), default=0)
later = [c for c in cuts if c > start]
shape = ('cuts', len(cuts))
index = 0 if mode == 'truncate' else self._resume_offset(
plugin_id, shape)
# Clamped rather than wrapped: a stale index past the end means the
# strip shrank, and restarting reads better than landing near the end.
start_index = index if 0 <= index < len(cuts) - 1 else 0
start = cuts[start_index]
later = cuts[start_index + 1:]
if not later:
end = img.width
else:
@@ -537,15 +663,22 @@ class PluginAdapter:
# No boundary inside the budget: take the next one and overrun,
# because the alternative is cutting through an item.
end = max(within) if within else min(later)
end = self._merge_trailing_runt(end, img.width, budget)
# Every candidate for `end` came from `cuts` (which includes img.width),
# so this always resolves; the fallback is defensive only.
end_index = cuts.index(end) if end in cuts else len(cuts) - 1
if mode != 'truncate':
# Next cycle resumes where this one stopped; wrap when the strip ends.
self._item_offsets[plugin_id] = 0 if end >= img.width else end
# Next cycle resumes at the boundary this one stopped on; wrap when
# the strip ends.
self._record_offset(
plugin_id, 0 if end >= img.width else end_index, shape)
logger.info(
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
"(%dpx) at item boundaries, %s",
"(%dpx) at item boundaries %d-%d of %d, %s",
plugin_id, budget, img.width, start, end, end - start,
start_index, end_index, len(cuts) - 1,
"showing the start only (overflow=truncate)"
if mode == 'truncate' else "window advances next cycle"
)
+6 -8
View File
@@ -30,16 +30,14 @@ def success_response(
"""
response_data = create_success_response(data, message, metadata)
# Add request metadata if available
if metadata is None:
metadata = {}
# Add timing if request start time is available
# Timing is merged into whatever the caller passed, without inventing a
# metadata block for responses that have neither.
enriched = dict(metadata) if metadata is not None else {}
if hasattr(request, 'start_time'):
metadata['response_time_ms'] = int((time.time() - request.start_time) * 1000)
enriched['response_time_ms'] = int((time.time() - request.start_time) * 1000)
if metadata:
response_data['metadata'] = metadata
if metadata is not None or enriched:
response_data['metadata'] = enriched
return jsonify(response_data)
+78 -2
View File
@@ -4,6 +4,7 @@ Centralized error handling for web interface.
Provides helpers for consistent error responses across API endpoints.
"""
import re
from typing import Any, Optional
from flask import jsonify
@@ -16,6 +17,78 @@ from src.logging_config import get_logger
logger = get_logger(__name__)
# Credentials that turn up inside exception text. A requests error quotes the
# URL it failed on, and plugins that authenticate by query string put their key
# there, so echoing an exception verbatim can hand out an API key. Redact the
# value, keep the parameter name -- knowing *which* credential was involved is
# part of the diagnosis.
_REDACT_CREDENTIAL = re.compile(
r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|'
r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)',
re.IGNORECASE,
)
# `Authorization: <scheme> <credential>`. The scheme name is kept because it
# says which kind of credential failed; the credential goes. Any scheme
# matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and
# whatever a plugin's API invents next are all credentials, and a list would
# silently leak the ones nobody thought of. Not covered by the generic pattern
# above, whose value part stops at whitespace and so would keep the credential
# once a space follows the scheme.
_REDACT_AUTH_HEADER = re.compile(
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
r'([^\s,"\'<>}]+)', # the credential, redacted
re.IGNORECASE,
)
# Credentials embedded in a URL: https://user:password@host. requests quotes
# the full URL in its exceptions, so this is a realistic leak. The username is
# kept -- it identifies which account failed without being the secret.
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
re.IGNORECASE)
# Long enough for an errno string with a path, short enough not to dump a
# parser's worth of context into a JSON field.
_MAX_DETAIL_LENGTH = 400
def describe_exception(exc: BaseException,
max_length: int = _MAX_DETAIL_LENGTH) -> str:
"""
One-line, safe-to-return description of an exception.
The generic "an error occurred; see logs for details" tells a user nothing
and, when the failure is bad enough, the logs are unreachable too: a device
whose storage was failing returned that message from every endpoint
*including* the log viewer, because journalctl could not be executed. The
underlying `[Errno 5] Input/output error` named the fault immediately.
Returns "TypeName: message", credentials redacted and length capped. The
type alone is worth carrying -- a bare PermissionError says more than any
generic sentence.
Args:
exc: The exception to describe
max_length: Truncate beyond this many characters
Returns:
A single-line description, never empty
"""
message = str(exc).strip()
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
# Order matters: the URL and header forms are more specific than the
# generic key=value pattern, which would otherwise chew the scheme.
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
text = _REDACT_AUTH_HEADER.sub(r'\1<redacted>', text)
text = _REDACT_CREDENTIAL.sub(r'\1<redacted>', text)
# Collapse newlines/tabs so the detail stays one line in a JSON field.
text = ' '.join(text.split())
if len(text) > max_length:
text = text[:max_length - 1].rstrip() + ''
return text
def create_error_response(
error_code: ErrorCode,
message: str,
@@ -69,13 +142,16 @@ def create_success_response(
"status": "success"
}
# All three use `is not None` rather than truthiness: "" and {} are
# values a caller chose to send, and dropping them silently would make
# the response shape depend on the data.
if data is not None:
response["data"] = data
if message:
if message is not None:
response["message"] = message
if metadata:
if metadata is not None:
response["metadata"] = metadata
return response
+5 -1
View File
@@ -89,7 +89,11 @@ class WebInterfaceError:
self.category = category or self._infer_category(error_code)
self.details = details
self.context = context or {}
self.suggested_fixes = suggested_fixes or self._get_default_suggestions(error_code)
# `is None`, not truthiness: an explicit [] means "this caller has
# no suggestions to offer", which the default list would override.
self.suggested_fixes = (
suggested_fixes if suggested_fixes is not None
else self._get_default_suggestions(error_code))
self.original_error = original_error
def _infer_category(self, error_code: ErrorCode) -> ErrorCategory:
+21 -6
View File
@@ -43,10 +43,15 @@ def validate_image_url(url: str) -> Tuple[bool, Optional[str]]:
if any(handler in url_lower for handler in ['onerror=', 'onload=', 'onclick=']):
return False, "Event handlers not allowed in URLs"
# Reject directory traversal anywhere, not only in relative paths:
# http://host/../secret is as much a traversal attempt as /../secret.
if '..' in url:
return False, "Invalid path: directory traversal not allowed"
# Allow relative paths starting with /
if url.startswith('/'):
# Validate it's a safe relative path (no directory traversal)
if '..' in url or url.startswith('//'):
# // would be a protocol-relative URL, not a local path
if url.startswith('//'):
return False, "Invalid relative path"
return True, None
@@ -104,10 +109,11 @@ def validate_file_upload(filename: str, max_size_mb: int = 10,
if '..' in filename or '/' in filename or '\\' in filename:
return False, "Filename contains invalid characters"
# Check extension if specified
# Check extension if specified. Both sides are lowercased: the caller's
# list is as likely to hold '.TTF' as the filename is.
if allowed_extensions:
file_ext = Path(filename).suffix.lower()
if file_ext not in allowed_extensions:
if file_ext not in [ext.lower() for ext in allowed_extensions]:
return False, f"File extension must be one of: {', '.join(allowed_extensions)}"
return True, None
@@ -147,7 +153,8 @@ def validate_numeric_range(value: float, min_val: Optional[float] = None,
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(value, (int, float)):
# bool is an int subclass, so True would otherwise validate as 1.
if not isinstance(value, (int, float)) or isinstance(value, bool):
return False, "Value must be a number"
if min_val is not None and value < min_val:
@@ -183,7 +190,15 @@ def validate_string_length(text: str, min_length: Optional[int] = None,
def sanitize_plugin_config(config: dict) -> dict:
"""
Sanitize plugin configuration input to prevent injection.
Restrict a plugin config to safe key names and value types.
Drops keys that are not plain identifiers and values that are not
JSON-ish scalars, lists, or dicts, recursing into the latter two.
String values are returned **unescaped**: output escaping is the
template layer's job, and escaping here would store the escaped form
in config.json. Do not read this function as XSS protection for
rendered output.
Args:
config: Configuration dictionary
+75
View File
@@ -0,0 +1,75 @@
"""
Shared scaffolding for api_v3 blueprint tests.
Not a test module (the leading underscore keeps pytest from collecting
it). It is the pytest-fixture equivalent of ``_make_client()`` in
test_uninstall_and_reconcile_endpoint.py, which is unittest-style and
requires ``self.addCleanup``.
The api_v3 blueprint keeps its managers as attributes on a module-level
singleton, not in Flask app state, so replacing them with mocks leaks
into every later test that imports api_v3 unless the originals are put
back. ``api_v3_client`` snapshots and restores them around each test.
"""
from unittest.mock import MagicMock
import pytest
from flask import Flask
# Every manager attribute the blueprint reads. Anything missing here keeps
# whatever a previously-run test left on the singleton.
API_V3_MANAGER_ATTRS = (
'config_manager', 'plugin_manager', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
)
_SENTINEL = object()
def build_app(blueprint):
app = Flask(__name__)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test'
app.register_blueprint(blueprint, url_prefix='/api/v3')
return app
@pytest.fixture
def api_v3_module():
"""The api_v3 module with every manager replaced by a MagicMock.
Restores the original attributes afterwards. Tests point individual
managers at real objects (a ConfigManager over tmp_path, say) or set
them to None to exercise the not-initialized branches.
"""
from web_interface.blueprints import api_v3 as module
originals = {
name: getattr(module.api_v3, name, _SENTINEL)
for name in API_V3_MANAGER_ATTRS
}
for name in API_V3_MANAGER_ATTRS:
setattr(module.api_v3, name, MagicMock())
# Default to the direct path; queue tests opt in explicitly.
module.api_v3.operation_queue = None
yield module
for name, original in originals.items():
if original is _SENTINEL:
if hasattr(module.api_v3, name):
try:
delattr(module.api_v3, name)
except AttributeError:
pass
else:
setattr(module.api_v3, name, original)
@pytest.fixture
def api_v3_client(api_v3_module):
"""Flask test client wired to the mocked blueprint."""
return build_app(api_v3_module.api_v3).test_client()
+275
View File
@@ -0,0 +1,275 @@
"""
Tests for src/common/api_helper.py (APIHelper).
Covers rate limiting, cached GETs, ESPN URL/cache-key construction,
session header defaults and per-call merging, the retry adapter, and the
fixed clear_cache() behavior (real CacheManager surface: clear_cache /
delete / list_cache_files, with safe no-ops elsewhere).
No real network: helper.session.get/post are always replaced with mocks.
"""
import types
from unittest.mock import MagicMock, Mock
import pytest
import requests
from freezegun import freeze_time
import src.common.api_helper as api_helper_module
from src.common.api_helper import APIHelper
def _make_response(payload):
response = MagicMock()
response.json.return_value = payload
response.raise_for_status.return_value = None
return response
@pytest.fixture
def cache():
cache = MagicMock()
cache.get.return_value = None
return cache
@pytest.fixture
def helper(cache):
helper = APIHelper(cache_manager=cache)
# Default min interval is 1.0s and would really sleep between requests.
helper.set_rate_limit(0)
return helper
# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------
class TestRateLimiting:
def test_sleeps_for_remaining_interval(self, helper, monkeypatch):
fake_time = MagicMock()
fake_time.time.side_effect = [102.0, 105.0]
monkeypatch.setattr(api_helper_module, 'time', fake_time)
helper.set_rate_limit(5)
helper._last_request_time = 100.0
helper._enforce_rate_limit()
# 2s elapsed of a 5s interval -> sleep the remaining 3s.
fake_time.sleep.assert_called_once()
assert fake_time.sleep.call_args[0][0] == pytest.approx(3.0)
assert helper._last_request_time == 105.0
def test_no_sleep_when_interval_elapsed(self, helper, monkeypatch):
fake_time = MagicMock()
fake_time.time.side_effect = [200.0, 201.0]
monkeypatch.setattr(api_helper_module, 'time', fake_time)
helper.set_rate_limit(5)
helper._last_request_time = 100.0
helper._enforce_rate_limit()
fake_time.sleep.assert_not_called()
assert helper._last_request_time == 201.0
# ---------------------------------------------------------------------------
# get()
# ---------------------------------------------------------------------------
class TestGet:
def test_cache_hit_skips_request_and_rate_limit(self, helper, cache):
cache.get.return_value = {'cached': True}
helper.session.get = Mock()
rate_spy = Mock()
helper._enforce_rate_limit = rate_spy
result = helper.get('https://example.com/api', cache_key='k')
assert result == {'cached': True}
helper.session.get.assert_not_called()
rate_spy.assert_not_called()
def test_cache_miss_fetches_and_caches_without_ttl(self, helper, cache):
cache.get.return_value = None
helper.session.get = Mock(return_value=_make_response({'a': 1}))
result = helper.get('https://example.com/api', cache_key='k',
cache_ttl=999)
assert result == {'a': 1}
# Pin the ttl-dropped contract: CacheManager.set is called with
# (key, data) only — the cache_ttl argument is discarded.
cache.set.assert_called_once_with('k', {'a': 1})
def test_request_exception_returns_none_and_caches_nothing(
self, helper, cache):
helper.session.get = Mock(
side_effect=requests.exceptions.RequestException('boom'))
result = helper.get('https://example.com/api', cache_key='k')
assert result is None
cache.set.assert_not_called()
def test_timeout_zero_falls_back_to_default(self, helper):
# Quirk pin: `timeout or self.default_timeout` treats an explicit
# timeout=0 as falsy, so the default (30) is used instead.
helper.session.get = Mock(return_value=_make_response({}))
helper.get('https://example.com/api', timeout=0)
assert helper.session.get.call_args.kwargs['timeout'] == 30
def test_per_call_headers_merge_over_session_headers(self, helper):
helper.session.get = Mock(return_value=_make_response({}))
helper.get('https://example.com/api', headers={'X-Custom': 'yes'})
sent = helper.session.get.call_args.kwargs['headers']
# Merged, not replaced: session defaults survive alongside the
# per-call header.
assert sent['X-Custom'] == 'yes'
assert sent['User-Agent'] == (
'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)')
assert sent['Accept'] == 'application/json'
# The session's own headers are not polluted by the per-call ones.
assert 'X-Custom' not in helper.session.headers
# ---------------------------------------------------------------------------
# ESPN helpers
# ---------------------------------------------------------------------------
class TestEspnHelpers:
@freeze_time('2026-08-07')
def test_fetch_espn_scoreboard_url_params_and_cache_key(self, helper):
helper.get = Mock(return_value={'ok': 1})
result = helper.fetch_espn_scoreboard('football', 'nfl')
assert result == {'ok': 1}
helper.get.assert_called_once_with(
'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard',
params={'dates': '20260807', 'limit': 1000},
cache_key='espn_football_nfl_20260807',
cache_ttl=300,
)
def test_fetch_espn_scoreboard_explicit_date(self, helper):
helper.get = Mock(return_value=None)
helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115')
kwargs = helper.get.call_args.kwargs
assert kwargs['params'] == {'dates': '20250115', 'limit': 1000}
assert kwargs['cache_key'] == 'espn_basketball_nba_20250115'
def test_fetch_espn_standings_url_and_cache_key(self, helper):
helper.get = Mock(return_value={'ok': 1})
helper.fetch_espn_standings('football', 'nfl')
helper.get.assert_called_once_with(
'https://site.api.espn.com/apis/site/v2/sports/football/nfl/standings',
cache_key='espn_standings_football_nfl',
cache_ttl=3600,
)
def test_fetch_espn_rankings_url_and_cache_key(self, helper):
helper.get = Mock(return_value={'ok': 1})
helper.fetch_espn_rankings('football', 'college-football')
helper.get.assert_called_once_with(
'https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings',
cache_key='espn_rankings_football_college-football',
cache_ttl=3600,
)
# ---------------------------------------------------------------------------
# Session setup
# ---------------------------------------------------------------------------
class TestSessionSetup:
def test_user_agent_exact(self, helper):
# Regression guard: ESPN began 403ing other user agents; this exact
# string must be sent on every request.
assert helper.session.headers['User-Agent'] == (
'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)')
def test_retry_adapter_configuration(self):
helper = APIHelper(cache_manager=None, max_retries=7)
retries = helper.session.get_adapter('https://x').max_retries
assert retries.total == 7
assert {429, 500, 502, 503, 504} <= set(retries.status_forcelist)
# ---------------------------------------------------------------------------
# clear_cache (fixed behavior: real CacheManager surface)
# ---------------------------------------------------------------------------
class TestClearCache:
def test_no_pattern_uses_clear_cache_method(self):
manager = types.SimpleNamespace(clear_cache=Mock())
helper = APIHelper(cache_manager=manager)
helper.set_rate_limit(0)
helper.clear_cache()
manager.clear_cache.assert_called_once_with()
def test_no_pattern_falls_back_to_clear(self):
manager = types.SimpleNamespace(clear=Mock())
helper = APIHelper(cache_manager=manager)
helper.set_rate_limit(0)
helper.clear_cache()
manager.clear.assert_called_once_with()
def test_no_pattern_manager_without_any_clear_is_noop(self):
helper = APIHelper(cache_manager=object())
helper.set_rate_limit(0)
helper.clear_cache() # must not raise
def test_pattern_deletes_only_matching_keys(self):
manager = types.SimpleNamespace(
list_cache_files=Mock(return_value=[
{'key': 'espn_nfl_x'},
{'key': 'other'},
]),
delete=Mock(),
)
helper = APIHelper(cache_manager=manager)
helper.set_rate_limit(0)
helper.clear_cache(pattern='espn')
manager.delete.assert_called_once_with('espn_nfl_x')
def test_pattern_manager_without_list_cache_files_is_noop(self):
helper = APIHelper(cache_manager=object())
helper.set_rate_limit(0)
helper.clear_cache(pattern='espn') # must not raise
# ---------------------------------------------------------------------------
# No cache manager
# ---------------------------------------------------------------------------
class TestNoCacheManager:
def test_all_cache_operations_safe_without_manager(self):
helper = APIHelper(cache_manager=None)
helper.set_rate_limit(0)
assert helper.get_cache('k') is None
assert helper._get_from_cache('k') is None
assert helper.set_cache('k', {'a': 1}) is None
assert helper.clear_cache() is None
assert helper.clear_cache(pattern='espn') is None
+226
View File
@@ -0,0 +1,226 @@
"""
Endpoint tests for POST /plugins/calendar/upload-credentials.
The endpoint takes an uploaded Google OAuth credentials file, writes it
into the calendar plugin's directory as credentials.json at mode 0600, and
copies any previous file aside first. It had no tests.
Regression coverage for two fixed bugs:
- The OAuth-shape check sat inside `except Exception: pass`, so a valid
JSON document that is not an object a bare `42`, a list, a string
raised TypeError on the membership test, was swallowed, and got saved
as credentials.json anyway.
- Each overwrite created a timestamped backup and nothing ever removed
them, so every re-upload left another complete copy of the user's OAuth
client credentials in the plugin directory, indefinitely.
"""
import io
import json
import os
import stat
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
URL = "/api/v3/plugins/calendar/upload-credentials"
VALID_CREDENTIALS = {
"installed": {
"client_id": "abc.apps.googleusercontent.com",
"client_secret": "shh",
"redirect_uris": ["http://localhost"],
}
}
@pytest.fixture
def plugin_dir(tmp_path, api_v3_module):
directory = tmp_path / "plugins" / "calendar"
directory.mkdir(parents=True)
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory)
return directory
def upload(client, content, filename="credentials.json"):
# bytes are sent verbatim (to exercise malformed input); anything else
# is serialized, so None becomes the JSON literal null rather than an
# empty body.
payload = content if isinstance(content, bytes) else json.dumps(content).encode()
return client.post(
URL,
data={"file": (io.BytesIO(payload), filename)},
content_type="multipart/form-data",
)
def backups(plugin_dir):
return sorted(plugin_dir.glob("credentials.json.backup.*"))
class TestRequestValidation:
def test_no_file_part_is_a_400(self, api_v3_client, plugin_dir):
response = api_v3_client.post(URL, data={}, content_type="multipart/form-data")
assert response.status_code == 400
assert "No file provided" in response.get_json()["message"]
def test_empty_filename_is_a_400(self, api_v3_client, plugin_dir):
response = upload(api_v3_client, VALID_CREDENTIALS, filename="")
assert response.status_code == 400
@pytest.mark.parametrize("filename", ["creds.txt", "creds.pem", "creds"])
def test_non_json_extension_is_a_400(self, api_v3_client, plugin_dir, filename):
response = upload(api_v3_client, VALID_CREDENTIALS, filename=filename)
assert response.status_code == 400
assert "JSON file" in response.get_json()["message"]
def test_uppercase_json_extension_accepted(self, api_v3_client, plugin_dir):
assert upload(api_v3_client, VALID_CREDENTIALS,
filename="CREDENTIALS.JSON").status_code == 200
def test_oversized_file_is_a_400(self, api_v3_client, plugin_dir):
response = upload(api_v3_client, b"x" * (1024 * 1024 + 1))
assert response.status_code == 400
assert "1MB" in response.get_json()["message"]
assert not (plugin_dir / "credentials.json").exists()
def test_invalid_json_is_a_400(self, api_v3_client, plugin_dir):
response = upload(api_v3_client, b"{not json")
assert response.status_code == 400
assert "not valid JSON" in response.get_json()["message"]
assert not (plugin_dir / "credentials.json").exists()
def test_missing_plugin_directory_is_a_404(self, api_v3_client, api_v3_module, tmp_path):
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(
tmp_path / "not-installed")
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 404
class TestOAuthShapeValidation:
def test_installed_key_accepted(self, api_v3_client, plugin_dir):
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 200
def test_web_key_accepted(self, api_v3_client, plugin_dir):
assert upload(api_v3_client, {"web": {"client_id": "x"}}).status_code == 200
def test_object_without_oauth_keys_is_a_400(self, api_v3_client, plugin_dir):
response = upload(api_v3_client, {"something": "else"})
assert response.status_code == 400
assert "valid Google OAuth" in response.get_json()["message"]
assert not (plugin_dir / "credentials.json").exists()
@pytest.mark.parametrize("content", [42, "a string", [1, 2, 3], True, None])
def test_valid_json_that_is_not_an_object_is_rejected(
self, api_v3_client, plugin_dir, content):
# Regression: `'installed' not in 42` raises TypeError, which the
# bare `except Exception: pass` swallowed — the file was then saved
# as credentials.json despite being unusable as credentials.
response = upload(api_v3_client, content)
assert response.status_code == 400
assert "valid Google OAuth" in response.get_json()["message"]
assert not (plugin_dir / "credentials.json").exists()
class TestSaving:
def test_file_written_with_contents_intact(self, api_v3_client, plugin_dir):
response = upload(api_v3_client, VALID_CREDENTIALS)
assert response.status_code == 200
saved = json.loads((plugin_dir / "credentials.json").read_text())
assert saved == VALID_CREDENTIALS
def test_response_reports_the_path(self, api_v3_client, plugin_dir):
body = upload(api_v3_client, VALID_CREDENTIALS).get_json()
assert body["path"].endswith("credentials.json")
def test_permissions_are_owner_only(self, api_v3_client, plugin_dir):
upload(api_v3_client, VALID_CREDENTIALS)
mode = stat.S_IMODE((plugin_dir / "credentials.json").stat().st_mode)
assert mode == 0o600
def test_first_upload_creates_no_backup(self, api_v3_client, plugin_dir):
upload(api_v3_client, VALID_CREDENTIALS)
assert backups(plugin_dir) == []
def test_overwrite_backs_up_the_previous_file(self, api_v3_client, plugin_dir):
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"old": 1}}))
upload(api_v3_client, VALID_CREDENTIALS)
assert len(backups(plugin_dir)) == 1
assert json.loads(backups(plugin_dir)[0].read_text()) == {"installed": {"old": 1}}
assert json.loads((plugin_dir / "credentials.json").read_text()) == VALID_CREDENTIALS
class TestBackupPruning:
def _seed(self, plugin_dir, count):
"""Create `count` backups with distinct, increasing mtimes."""
now = int(time.time())
for i in range(count):
path = plugin_dir / f"credentials.json.backup.{now - (count - i) * 10}"
path.write_text(json.dumps({"installed": {"gen": i}}))
os.utime(path, (now - (count - i) * 10, now - (count - i) * 10))
def test_old_backups_are_pruned(self, api_v3_client, plugin_dir):
# Regression: nothing ever removed these, so a plugin directory
# accumulated one full copy of the user's OAuth credentials per
# re-upload, forever.
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
self._seed(plugin_dir, 7)
assert len(backups(plugin_dir)) == 7
upload(api_v3_client, VALID_CREDENTIALS)
assert len(backups(plugin_dir)) == 5
def test_the_newest_backups_are_the_ones_kept(self, api_v3_client, plugin_dir):
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
self._seed(plugin_dir, 7)
upload(api_v3_client, VALID_CREDENTIALS)
remaining = backups(plugin_dir)
# The just-created backup (of "cur") plus the four newest seeds.
contents = [json.loads(p.read_text()) for p in remaining]
assert {"installed": {"cur": 1}} in contents
assert {"installed": {"gen": 0}} not in contents # oldest seed gone
def test_under_the_limit_nothing_is_removed(self, api_v3_client, plugin_dir):
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
self._seed(plugin_dir, 2)
upload(api_v3_client, VALID_CREDENTIALS)
assert len(backups(plugin_dir)) == 3 # 2 seeded + 1 new
def test_repeated_uploads_stay_bounded(
self, api_v3_client, plugin_dir, api_v3_module, monkeypatch):
# The backup filename carries int(time.time()), so uploads inside
# the same second all write the same name and overwrite each other.
# Advance a fake clock a second per round — otherwise this never
# reaches six backups and the bound holds for the wrong reason.
clock = {"now": int(time.time())}
monkeypatch.setattr(
api_v3_module, "time", SimpleNamespace(time=lambda: clock["now"]))
for i in range(10):
clock["now"] += 1
upload(api_v3_client, {"installed": {"round": i}})
os.utime(plugin_dir / "credentials.json",
(clock["now"], clock["now"]))
remaining = backups(plugin_dir)
assert len(remaining) == 5
# And they are the five most recent rounds, not an arbitrary five.
kept = sorted(int(p.name.rsplit(".", 1)[1]) for p in remaining)
assert kept == [clock["now"] - 4 + i for i in range(5)]
def test_unremovable_backup_does_not_fail_the_upload(
self, api_v3_client, plugin_dir, monkeypatch):
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
self._seed(plugin_dir, 7)
def refuse(self):
raise OSError("read-only filesystem")
monkeypatch.setattr(Path, "unlink", refuse)
# Pruning is housekeeping; failing it must not lose the upload.
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 200
+302
View File
@@ -0,0 +1,302 @@
"""
Endpoint tests for /plugins/authenticate/spotify and .../ytm.
The Spotify step-2 handler writes a Python wrapper script to a temp file
with the user's redirect URL embedded in it, then runs that file through
subprocess. That is the most dangerous shape in the blueprint and had no
tests: the URL is user input reaching generated source code.
The two endpoints are NOT symmetrical, despite the matching names. Only
Spotify has a two-step flow, a wrapper script, and a redirect_url; YTM
just runs its script directly.
Regression coverage for one fixed bug: the wrapper file was unlinked in
the success/failure branch and again in the TimeoutExpired handler, so
any other failure from subprocess.run the interpreter missing, a fork
failure, an interrupted call left a temp file containing the user's
redirect URL behind.
"""
import ast
import json
import os
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
@pytest.fixture
def plugin_dir(tmp_path, api_v3_module):
"""A plugin directory containing both auth scripts."""
directory = tmp_path / "plugins" / "ledmatrix-music"
directory.mkdir(parents=True)
(directory / "authenticate_spotify.py").write_text("print('spotify')\n")
(directory / "authenticate_ytm.py").write_text("print('ytm')\n")
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory)
return directory
def completed(returncode=0, stdout="ok", stderr=""):
return subprocess.CompletedProcess(
args=["python3"], returncode=returncode, stdout=stdout, stderr=stderr)
class TestSpotifyPreconditions:
URL = "/api/v3/plugins/authenticate/spotify"
def test_missing_plugin_directory_is_404(self, api_v3_client, api_v3_module, tmp_path):
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(
tmp_path / "not-installed")
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 404
assert response.get_json()["message"] == "Plugin not found"
def test_none_plugin_directory_is_404(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = None
assert api_v3_client.post(self.URL, json={}).status_code == 404
def test_missing_auth_script_is_404(self, api_v3_client, plugin_dir):
(plugin_dir / "authenticate_spotify.py").unlink()
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 404
assert "script not found" in response.get_json()["message"]
class TestSpotifyStepTwo:
"""redirect_url present — the wrapper-script path."""
URL = "/api/v3/plugins/authenticate/spotify"
def test_success(self, api_v3_client, plugin_dir):
with patch.object(subprocess, "run", return_value=completed(0, "done")):
response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
assert response.status_code == 200
body = response.get_json()
assert body["status"] == "success"
assert body["output"] == "done"
def test_script_failure_is_a_400_with_combined_output(self, api_v3_client, plugin_dir):
with patch.object(subprocess, "run", return_value=completed(1, "out", "err")):
response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
assert response.status_code == 400
assert response.get_json()["output"] == "outerr"
def test_timeout_is_a_408(self, api_v3_client, plugin_dir):
with patch.object(subprocess, "run",
side_effect=subprocess.TimeoutExpired("python3", 120)):
response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
assert response.status_code == 408
assert "timed out" in response.get_json()["message"]
def test_runs_a_list_argv_never_a_shell(self, api_v3_client, plugin_dir):
with patch.object(subprocess, "run", return_value=completed()) as run:
api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
args, kwargs = run.call_args
assert isinstance(args[0], list)
assert args[0][0] == "python3"
assert kwargs.get("shell") in (None, False)
def test_timeout_is_bounded(self, api_v3_client, plugin_dir):
with patch.object(subprocess, "run", return_value=completed()) as run:
api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
assert run.call_args.kwargs["timeout"] == 120
class TestSpotifyWrapperCleanup:
URL = "/api/v3/plugins/authenticate/spotify"
def _wrapper_paths_after(self, api_v3_client, run_mock):
"""Run the endpoint and return the wrapper path subprocess saw."""
seen = {}
def capture(args, **kwargs):
seen["path"] = args[1]
return run_mock(args, **kwargs)
with patch.object(subprocess, "run", side_effect=capture):
api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
return seen["path"]
def test_removed_after_success(self, api_v3_client, plugin_dir):
path = self._wrapper_paths_after(api_v3_client, lambda *a, **kw: completed())
assert not os.path.exists(path)
def test_removed_after_script_failure(self, api_v3_client, plugin_dir):
path = self._wrapper_paths_after(
api_v3_client, lambda *a, **kw: completed(1, "out", "err"))
assert not os.path.exists(path)
def test_removed_after_timeout(self, api_v3_client, plugin_dir):
def raise_timeout(*a, **kw):
raise subprocess.TimeoutExpired("python3", 120)
path = self._wrapper_paths_after(api_v3_client, raise_timeout)
assert not os.path.exists(path)
def test_removed_when_subprocess_cannot_start(self, api_v3_client, plugin_dir):
# Regression: cleanup lived in the success/failure branch and in the
# TimeoutExpired handler only. An OSError from subprocess.run itself
# — no interpreter, fork failure — skipped both and left the wrapper,
# which contains the user's redirect URL, on disk.
def raise_oserror(*a, **kw):
raise OSError("[Errno 12] Cannot allocate memory")
path = self._wrapper_paths_after(api_v3_client, raise_oserror)
assert not os.path.exists(path)
class TestSpotifyRedirectUrlIsNotInjectable:
"""The wrapper embeds redirect_url into generated Python source."""
URL = "/api/v3/plugins/authenticate/spotify"
ADVERSARIAL = [
'''http://cb/?code=x"''',
"""http://cb/?code=x'""",
'http://cb/?code=x\\',
'http://cb/?code=x\nimport os; os.system("id")',
'http://cb/?code=x"""\nimport os\n"""',
"http://cb/?code=x'''",
'http://cb/?code=x\\"\\n',
'"; import os; os.system("id"); "',
]
def _wrapper_source(self, api_v3_client, redirect_url):
captured = {}
def capture(args, **kwargs):
captured["source"] = Path(args[1]).read_text()
return completed()
with patch.object(subprocess, "run", side_effect=capture):
api_v3_client.post(self.URL, json={"redirect_url": redirect_url})
return captured["source"]
@pytest.mark.parametrize("redirect_url", ADVERSARIAL)
def test_wrapper_is_still_valid_python(self, api_v3_client, plugin_dir, redirect_url):
# If escaping failed, the generated file would not parse at all.
source = self._wrapper_source(api_v3_client, redirect_url)
ast.parse(source)
@pytest.mark.parametrize("redirect_url", ADVERSARIAL)
def test_url_survives_as_one_string_literal(
self, api_v3_client, plugin_dir, redirect_url):
# Stronger than "it parses": the URL must still be a single string
# assigned to redirect_url, not code that escaped into statements.
source = self._wrapper_source(api_v3_client, redirect_url)
tree = ast.parse(source)
assigned = [
node.value.value for node in ast.walk(tree)
if isinstance(node, ast.Assign)
and isinstance(node.value, ast.Constant)
and any(getattr(t, "id", None) == "redirect_url" for t in node.targets)
]
assert assigned == [redirect_url.strip()]
def test_injected_call_does_not_become_a_statement(self, api_v3_client, plugin_dir):
source = self._wrapper_source(
api_v3_client, 'http://cb/\nimport os; os.system("id")')
tree = ast.parse(source)
imported = {
alias.name for node in ast.walk(tree)
if isinstance(node, ast.Import) for alias in node.names
}
# The wrapper legitimately imports sys, subprocess and os; what it
# must not gain is a *call* smuggled in through the URL.
calls = [
node for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "system"
]
assert calls == []
class TestSpotifyStepOne:
"""No redirect_url — the OAuth-URL path, which imports the script."""
URL = "/api/v3/plugins/authenticate/spotify"
def test_script_without_credentials_helper_is_an_error(
self, api_v3_client, plugin_dir):
# The stub script defines neither get_auth_url nor
# load_spotify_credentials, so no URL can be produced.
response = api_v3_client.post(self.URL, json={})
assert response.status_code in (400, 500)
assert response.get_json()["status"] == "error"
def test_unusable_credentials_do_not_leak_into_the_response(
self, api_v3_client, plugin_dir):
(plugin_dir / "authenticate_spotify.py").write_text(
"def load_spotify_credentials():\n"
" return ('id-abc', 'super-secret-value', None)\n"
)
response = api_v3_client.post(self.URL, json={})
assert "super-secret-value" not in response.get_data(as_text=True)
def test_script_raising_on_import_is_handled(self, api_v3_client, plugin_dir):
(plugin_dir / "authenticate_spotify.py").write_text("raise RuntimeError('boom')\n")
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 500
assert response.get_json()["status"] == "error"
def test_bodyless_post_reaches_step_one(self, api_v3_client, plugin_dir):
# Covered by the silent=True fix: previously a 500 from body parsing.
response = api_v3_client.post(self.URL)
assert response.status_code in (400, 500)
assert response.get_json()["status"] == "error"
def test_whitespace_redirect_url_is_treated_as_absent(
self, api_v3_client, plugin_dir):
with patch.object(subprocess, "run", return_value=completed()) as run:
api_v3_client.post(self.URL, json={"redirect_url": " "})
# Step 2 never runs, so no wrapper is executed.
run.assert_not_called()
class TestYouTubeMusic:
"""No wrapper script and no redirect_url — deliberately not symmetric."""
URL = "/api/v3/plugins/authenticate/ytm"
def test_missing_plugin_directory_is_404(self, api_v3_client, api_v3_module, tmp_path):
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(
tmp_path / "not-installed")
assert api_v3_client.post(self.URL).status_code == 404
def test_missing_script_is_404(self, api_v3_client, plugin_dir):
(plugin_dir / "authenticate_ytm.py").unlink()
response = api_v3_client.post(self.URL)
assert response.status_code == 404
assert "script not found" in response.get_json()["message"]
def test_success(self, api_v3_client, plugin_dir):
with patch.object(subprocess, "run", return_value=completed(0, "authorized")):
response = api_v3_client.post(self.URL)
assert response.status_code == 200
assert response.get_json()["output"] == "authorized"
def test_failure_is_a_400_with_combined_output(self, api_v3_client, plugin_dir):
with patch.object(subprocess, "run", return_value=completed(1, "out", "err")):
response = api_v3_client.post(self.URL)
assert response.status_code == 400
assert response.get_json()["output"] == "outerr"
def test_timeout_is_a_408(self, api_v3_client, plugin_dir):
with patch.object(subprocess, "run",
side_effect=subprocess.TimeoutExpired("python3", 60)):
assert api_v3_client.post(self.URL).status_code == 408
def test_runs_the_script_directly_without_a_shell(self, api_v3_client, plugin_dir):
with patch.object(subprocess, "run", return_value=completed()) as run:
api_v3_client.post(self.URL)
args, kwargs = run.call_args
assert args[0][0] == "python3"
assert args[0][1].endswith("authenticate_ytm.py")
assert kwargs.get("shell") in (None, False)
assert kwargs["timeout"] == 60
+136
View File
@@ -0,0 +1,136 @@
"""
Regression tests: POST endpoints whose body is optional must accept a
request that has no body at all.
Six handlers in api_v3 read their body as ``request.get_json() or {}``.
The ``or {}`` states the intent plainly every field is optional, so a
bodyless POST should fall back to defaults. But ``get_json()`` without
``silent=True`` raises ``UnsupportedMediaType`` when the request carries
no JSON Content-Type, and it raises *before* ``or {}`` is evaluated. Each
handler's catch-all then turned that into a 500.
So the natural way to call these endpoints a POST with no body, which
is what curl, a fetch() without options, and most HTTP clients send by
default failed on every one of them. The shipped UI always sends a JSON
object, which is why this went unnoticed.
This file covers the endpoints whose bodyless behaviour is not already
tested in their own suite.
"""
import re
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
class TestOnDemandStart:
URL = "/api/v3/display/on-demand/start"
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
response = api_v3_client.post(self.URL)
# The endpoint may still reject the request on its own terms (no
# plugin_id, nothing to display); what it must not do is fail with
# a 500 raised out of body parsing.
assert response.status_code != 500
def test_json_body_still_works(self, api_v3_client, api_v3_module):
assert api_v3_client.post(self.URL, json={}).status_code != 500
class TestResetPluginConfig:
URL = "/api/v3/plugins/config/reset"
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
assert api_v3_client.post(self.URL).status_code != 500
def test_json_body_still_works(self, api_v3_client, api_v3_module):
assert api_v3_client.post(self.URL, json={}).status_code != 500
class TestDeleteOfTheDayJson:
URL = "/api/v3/plugins/of-the-day/json/delete"
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
assert api_v3_client.post(self.URL).status_code != 500
def test_json_body_still_works(self, api_v3_client, api_v3_module):
assert api_v3_client.post(self.URL, json={}).status_code != 500
class TestPluginLimits:
URL = "/api/v3/plugins/clock/limits"
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
assert api_v3_client.post(self.URL).status_code != 500
class TestMissingBodyGivesTheDeclaredError:
"""Handlers that answer "No data provided" must actually be able to.
A second group of handlers reads `data = request.get_json()` and then
guards with `if not data: return 400`. That guard is unreachable for a
request with no JSON body, because get_json() raises first so the
caller got a 500 "an error occurred; see logs for details" instead of
the 400 the handler plainly intends to send.
"""
@pytest.mark.parametrize("url", [
"/api/v3/plugins/install",
"/api/v3/plugins/install-from-url",
"/api/v3/plugins/registry-from-url",
"/api/v3/config/raw/main",
"/api/v3/config/raw/secrets",
"/api/v3/cache/delete",
])
def test_bodyless_post_gets_a_400_not_a_500(self, api_v3_client, api_v3_module, url):
response = api_v3_client.post(url)
assert response.status_code == 400, (
f"{url} answered {response.status_code}: "
f"{response.get_data(as_text=True)[:200]}")
@pytest.mark.parametrize("url", [
"/api/v3/plugins/install",
"/api/v3/config/raw/main",
])
def test_malformed_json_gets_a_400_not_a_500(self, api_v3_client, api_v3_module, url):
response = api_v3_client.post(
url, data="{not json", content_type="application/json")
assert response.status_code == 400
class TestNoBodyReadContradictsItsOwnGuard:
SOURCE = Path(__file__).parent.parent / "web_interface/blueprints/api_v3.py"
def test_no_or_default_read_is_unguarded(self):
"""`get_json() or <default>` is a contradiction without silent=True.
Writing `or {}` declares the body optional; omitting silent=True
means the call raises before the default can apply.
"""
offenders = [
line.strip() for line in self.SOURCE.read_text().splitlines()
if "request.get_json()" in line and " or " in line
]
assert offenders == [], (
"these reads declare a default but raise before reaching it; "
f"use get_json(silent=True): {offenders}")
def test_no_not_data_guard_is_unreachable(self):
"""A `if not data:` guard needs a read that can actually return None."""
lines = self.SOURCE.read_text().splitlines()
offenders = []
for i, line in enumerate(lines):
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
window = "\n".join(lines[i + 1:i + 3])
if re.search(r"if\s+(not\s+data\b|data\s+is\s+None)", window):
offenders.append(f"line {i + 1}: {line.strip()}")
assert offenders == [], (
"these handlers guard on a missing body but raise before the "
f"guard runs; use get_json(silent=True): {offenders}")
@@ -0,0 +1,302 @@
"""
Endpoint tests for POST /plugins/install and POST /plugins/install-from-url.
Both were only ever tested at the PluginStoreManager layer, so the route
logic the queue-vs-direct branch, schema invalidation, plugin discovery,
state and history recording was unexercised.
/plugins/install carries the same install logic twice: once inside the
operation-queue callback and once in the direct fallback. The paired
tests below assert both branches produce the same side effects, so the
duplication cannot quietly drift.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
INSTALL = "/api/v3/plugins/install"
FROM_URL = "/api/v3/plugins/install-from-url"
@pytest.fixture
def queued(api_v3_module):
"""Enable the operation queue and run its callback synchronously."""
queue = MagicMock()
def enqueue(operation_type, plugin_id, operation_callback=None):
queue.callback_result = operation_callback(MagicMock())
return "op-123"
queue.enqueue_operation.side_effect = enqueue
api_v3_module.api_v3.operation_queue = queue
return queue
def side_effects(module):
"""The manager calls a successful install is expected to make."""
api = module.api_v3
return {
"schema_invalidated": api.schema_manager.invalidate_cache.call_args_list,
"discovered": api.plugin_manager.discover_plugins.call_count,
"loaded": api.plugin_manager.load_plugin.call_args_list,
"state_set": api.plugin_state_manager.set_plugin_installed.call_args_list,
"history": api.operation_history.record_operation.call_args_list,
}
class TestInstallValidation:
def test_uninitialized_store_manager_is_a_500(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager = None
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
assert response.status_code == 500
assert "not initialized" in response.get_json()["message"]
def test_missing_plugin_id_is_a_400(self, api_v3_client, api_v3_module):
response = api_v3_client.post(INSTALL, json={})
assert response.status_code == 400
assert "plugin_id required" in response.get_json()["message"]
api_v3_module.api_v3.plugin_store_manager.install_plugin.assert_not_called()
def test_empty_body_is_a_400(self, api_v3_client, api_v3_module):
assert api_v3_client.post(INSTALL, json=None).status_code == 400
class TestInstallDirectPath:
"""operation_queue is None — the fallback branch."""
def test_success(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
assert response.status_code == 200
assert response.get_json()["status"] == "success"
def test_success_side_effects(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == [(("clock",), {})]
assert effects["discovered"] == 1
assert effects["loaded"] == [(("clock",), {})]
assert effects["state_set"] == [(("clock",), {})]
assert effects["history"][0].kwargs["status"] == "success"
def test_branch_forwarded_to_the_manager(self, api_v3_client, api_v3_module):
manager = api_v3_module.api_v3.plugin_store_manager
manager.install_plugin.return_value = True
api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
manager.install_plugin.assert_called_once_with("clock", branch="dev")
def test_branch_named_in_the_message(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
assert "(branch: dev)" in response.get_json()["message"]
def test_failure_is_a_500(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
assert response.status_code == 500
assert "Failed to install" in response.get_json()["message"]
def test_failure_mentions_missing_registry_entry(self, api_v3_client, api_v3_module):
manager = api_v3_module.api_v3.plugin_store_manager
manager.install_plugin.return_value = False
manager.get_plugin_info.return_value = None
response = api_v3_client.post(INSTALL, json={"plugin_id": "ghost"})
assert "not found in registry" in response.get_json()["message"]
def test_failure_omits_registry_note_when_plugin_is_known(
self, api_v3_client, api_v3_module):
manager = api_v3_module.api_v3.plugin_store_manager
manager.install_plugin.return_value = False
manager.get_plugin_info.return_value = {"id": "clock"}
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
assert "not found in registry" not in response.get_json()["message"]
def test_failure_recorded_in_history(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
record = api_v3_module.api_v3.operation_history.record_operation.call_args
assert record.kwargs["status"] == "failed"
def test_no_side_effects_on_failure(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == []
assert effects["loaded"] == []
assert effects["state_set"] == []
class TestInstallQueuedPath:
"""operation_queue present — the callback branch."""
def test_returns_an_operation_id(self, api_v3_client, api_v3_module, queued):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
assert response.status_code == 200
assert response.get_json()["data"]["operation_id"] == "op-123"
def test_message_says_queued(self, api_v3_client, api_v3_module, queued):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
assert "queued" in response.get_json()["message"]
def test_callback_success_side_effects(self, api_v3_client, api_v3_module, queued):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == [(("clock",), {})]
assert effects["discovered"] == 1
assert effects["loaded"] == [(("clock",), {})]
assert effects["state_set"] == [(("clock",), {})]
assert effects["history"][0].kwargs["status"] == "success"
def test_callback_reports_success(self, api_v3_client, api_v3_module, queued):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
assert queued.callback_result["success"] is True
def test_callback_failure_raises_for_the_queue(self, api_v3_client, api_v3_module, queued):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
# The callback signals failure by raising, so the queue can mark the
# operation failed; the route's catch-all turns it into a 500.
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
assert response.status_code == 500
def test_callback_failure_recorded_in_history(self, api_v3_client, api_v3_module, queued):
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
record = api_v3_module.api_v3.operation_history.record_operation.call_args
assert record.kwargs["status"] == "failed"
def test_branch_forwarded_from_the_callback(self, api_v3_client, api_v3_module, queued):
manager = api_v3_module.api_v3.plugin_store_manager
manager.install_plugin.return_value = True
api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
manager.install_plugin.assert_called_once_with("clock", branch="dev")
class TestInstallPathsAgree:
"""The queue callback and the direct fallback duplicate the same logic."""
def _run(self, client, module, install_ok, queue):
module.api_v3.plugin_store_manager.install_plugin.return_value = install_ok
client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
return side_effects(module)
def test_success_side_effects_match(self, api_v3_client, api_v3_module):
direct = self._run(api_v3_client, api_v3_module, True, None)
# Reset and re-run through the queue.
for mock in (api_v3_module.api_v3.schema_manager,
api_v3_module.api_v3.plugin_manager,
api_v3_module.api_v3.plugin_state_manager,
api_v3_module.api_v3.operation_history):
mock.reset_mock()
queue = MagicMock()
queue.enqueue_operation.side_effect = (
lambda t, p, operation_callback=None: operation_callback(MagicMock()) and "op")
api_v3_module.api_v3.operation_queue = queue
queued = self._run(api_v3_client, api_v3_module, True, queue)
assert direct["schema_invalidated"] == queued["schema_invalidated"]
assert direct["discovered"] == queued["discovered"]
assert direct["loaded"] == queued["loaded"]
assert direct["state_set"] == queued["state_set"]
assert (direct["history"][0].kwargs["status"]
== queued["history"][0].kwargs["status"])
assert (direct["history"][0].kwargs["details"]
== queued["history"][0].kwargs["details"])
def test_only_the_message_wording_differs(self, api_v3_client, api_v3_module):
# Characterized: the direct path says "Plugin installed
# successfully" while the queue callback says "Plugin clock
# installed successfully". Cosmetic, and the queue's text is
# internal to the operation record rather than the HTTP response.
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
direct = api_v3_client.post(INSTALL, json={"plugin_id": "clock"}).get_json()
assert direct["message"] == "Plugin installed successfully"
class TestInstallFromUrl:
def test_uninitialized_store_manager_is_a_500(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager = None
assert api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).status_code == 500
def test_missing_repo_url_is_a_400(self, api_v3_client, api_v3_module):
response = api_v3_client.post(FROM_URL, json={})
assert response.status_code == 400
assert "repo_url required" in response.get_json()["message"]
def test_success(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
"success": True, "plugin_id": "clock", "name": "Clock"}
response = api_v3_client.post(FROM_URL, json={"repo_url": "https://github.com/o/r"})
assert response.status_code == 200
body = response.get_json()
assert body["plugin_id"] == "clock"
assert body["name"] == "Clock"
def test_all_optional_arguments_forwarded(self, api_v3_client, api_v3_module):
manager = api_v3_module.api_v3.plugin_store_manager
manager.install_from_url.return_value = {"success": True, "plugin_id": "clock"}
api_v3_client.post(FROM_URL, json={
"repo_url": " https://github.com/o/r ",
"plugin_id": "clock",
"plugin_path": "plugins/clock",
"branch": "dev",
})
manager.install_from_url.assert_called_once_with(
repo_url="https://github.com/o/r",
plugin_id="clock",
plugin_path="plugins/clock",
branch="dev",
)
def test_success_invalidates_schema_and_loads_plugin(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
"success": True, "plugin_id": "clock"}
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_called_once_with("clock")
api_v3_module.api_v3.plugin_manager.load_plugin.assert_called_once_with("clock")
def test_success_without_plugin_id_skips_discovery(self, api_v3_client, api_v3_module):
# install_from_url can succeed without naming the plugin; there is
# then nothing to invalidate or load.
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
"success": True, "plugin_id": None}
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_not_called()
api_v3_module.api_v3.plugin_manager.load_plugin.assert_not_called()
def test_branch_from_result_included(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
"success": True, "plugin_id": "clock", "branch": "dev"}
body = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).get_json()
assert body["branch"] == "dev"
assert "(branch: dev)" in body["message"]
def test_failure_reports_the_managers_error(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
"success": False, "error": "repo not found"}
response = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
assert response.status_code == 500
assert response.get_json()["message"] == "repo not found"
def test_failure_without_error_uses_fallback_text(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
"success": False}
response = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
assert "Failed to install plugin from URL" in response.get_json()["message"]
def test_manager_exception_is_a_500(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_from_url.side_effect = (
RuntimeError("boom"))
assert api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).status_code == 500
+179
View File
@@ -0,0 +1,179 @@
"""
Endpoint tests for the plugin-registry routes in api_v3:
POST /plugins/store/refresh and POST /plugins/registry-from-url.
Both reach out to the network through PluginStoreManager (mocked here) and
had no endpoint-level coverage; registry-from-url in particular takes a
user-supplied URL and hands it straight to the manager.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
class TestRefreshPluginStore:
URL = "/api/v3/plugins/store/refresh"
def test_uninitialized_manager_is_a_500(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager = None
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 500
assert "not initialized" in response.get_json()["message"]
def test_success_reports_plugin_count(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {
"plugins": [{"id": "a"}, {"id": "b"}, {"id": "c"}]}
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 200
assert response.get_json()["plugin_count"] == 3
def test_forces_a_refresh_rather_than_using_cache(self, api_v3_client, api_v3_module):
manager = api_v3_module.api_v3.plugin_store_manager
manager.fetch_registry.return_value = {"plugins": []}
api_v3_client.post(self.URL, json={})
manager.fetch_registry.assert_called_once_with(force_refresh=True)
def test_empty_registry_reports_zero(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {}
response = api_v3_client.post(self.URL, json={})
assert response.get_json()["plugin_count"] == 0
def test_no_body_is_accepted(self, api_v3_client, api_v3_module):
# Regression: `request.get_json() or {}` says a missing body is
# fine, but get_json() raises UnsupportedMediaType before `or {}`
# is reached, so a bodyless POST — the natural way to call a
# refresh endpoint — came back 500.
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
assert api_v3_client.post(self.URL).status_code == 200
def test_body_without_json_content_type_is_accepted(
self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
response = api_v3_client.post(self.URL, data="", content_type="text/plain")
assert response.status_code == 200
def test_malformed_json_body_falls_back_to_defaults(
self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
response = api_v3_client.post(
self.URL, data="{not json", content_type="application/json")
assert response.status_code == 200
@pytest.mark.parametrize("key", ["fetch_commit_info", "fetch_latest_versions"])
def test_either_commit_info_key_extends_the_message(
self, api_v3_client, api_v3_module, key):
# fetch_latest_versions is the older spelling; both must work.
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
response = api_v3_client.post(self.URL, json={key: True})
assert "commit metadata" in response.get_json()["message"]
def test_message_stays_plain_without_the_flag(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
response = api_v3_client.post(self.URL, json={})
assert response.get_json()["message"] == "Plugin store refreshed"
def test_network_failure_is_a_500(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry.side_effect = (
ConnectionError("github unreachable"))
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 500
assert response.get_json()["message"] == "An error occurred; see logs for details"
def test_failure_body_carries_no_traceback_or_paths(
self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry.side_effect = (
RuntimeError("failed at /home/user/LEDMatrix/src/secret.py line 42"))
body = api_v3_client.post(self.URL, json={}).get_json()
assert "Traceback" not in str(body)
# `details` is describe_exception output: one line, type-named,
# credential-redacted. It may quote the message, but never a stack.
assert body["details"].startswith("RuntimeError:")
assert "\n" not in body["details"]
class TestRegistryFromUrl:
URL = "/api/v3/plugins/registry-from-url"
def test_uninitialized_manager_is_a_500(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager = None
response = api_v3_client.post(self.URL, json={"repo_url": "http://x"})
assert response.status_code == 500
def test_missing_repo_url_is_a_400(self, api_v3_client, api_v3_module):
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 400
assert "repo_url required" in response.get_json()["message"]
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.assert_not_called()
def test_success_returns_the_plugin_list(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = {
"plugins": [{"id": "clock"}]}
response = api_v3_client.post(
self.URL, json={"repo_url": "https://github.com/o/r"})
assert response.status_code == 200
body = response.get_json()
assert body["plugins"] == [{"id": "clock"}]
assert body["registry_url"] == "https://github.com/o/r"
def test_url_is_trimmed_before_use(self, api_v3_client, api_v3_module):
manager = api_v3_module.api_v3.plugin_store_manager
manager.fetch_registry_from_url.return_value = {"plugins": []}
api_v3_client.post(self.URL, json={"repo_url": " https://github.com/o/r "})
manager.fetch_registry_from_url.assert_called_once_with("https://github.com/o/r")
def test_registry_without_plugins_key_returns_empty_list(
self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = {
"other": 1}
response = api_v3_client.post(self.URL, json={"repo_url": "http://x"})
assert response.get_json()["plugins"] == []
def test_no_registry_found_is_a_400(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = None
response = api_v3_client.post(self.URL, json={"repo_url": "http://x/not-a-registry"})
assert response.status_code == 400
assert "Failed to fetch registry" in response.get_json()["message"]
@pytest.mark.parametrize("url", [
"not a url",
"javascript:alert(1)",
"file:///etc/passwd",
"http://localhost:8080/admin",
])
def test_unusable_urls_fail_cleanly(self, api_v3_client, api_v3_module, url):
# Characterization: the handler performs no URL validation of its
# own — whatever the manager makes of the URL decides the outcome.
# What is pinned here is that a rejected URL produces a clean 400
# rather than a traceback or a 500.
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = None
response = api_v3_client.post(self.URL, json={"repo_url": url})
assert response.status_code == 400
assert "Traceback" not in str(response.get_json())
def test_fetch_exception_is_a_500_without_internals(
self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.side_effect = (
ValueError("parse failed in /srv/app/internal.py"))
response = api_v3_client.post(self.URL, json={"repo_url": "http://x"})
assert response.status_code == 500
body = response.get_json()
assert body["message"] == "An error occurred; see logs for details"
assert "Traceback" not in str(body)
def test_non_string_repo_url_is_rejected(self, api_v3_client, api_v3_module):
# Regression: .strip() on a non-string raised, and the catch-all
# reported the caller's own mistake as a server fault.
response = api_v3_client.post(self.URL, json={"repo_url": 12345})
assert response.status_code == 400
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.assert_not_called()
def test_blank_repo_url_is_rejected(self, api_v3_client, api_v3_module):
response = api_v3_client.post(self.URL, json={"repo_url": " "})
assert response.status_code == 400
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.assert_not_called()
+240
View File
@@ -0,0 +1,240 @@
"""
Endpoint tests for the /wifi/* routes in api_v3.
These routes drive the host's actual networking — connecting, dropping a
connection, switching the radio off and had no endpoint-level tests at
all. WiFiManager is mocked throughout; nothing here may touch real
networking.
Each handler does `from src.wifi_manager import WiFiManager` inside the
function body, so the patch target is the class at its definition site.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
@pytest.fixture
def wifi_manager():
"""Patch WiFiManager where it is defined; yield the instance mock."""
with patch("src.wifi_manager.WiFiManager") as cls:
instance = MagicMock()
cls.return_value = instance
yield instance
class TestConnect:
URL = "/api/v3/wifi/connect"
def test_success(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "pw"})
assert response.status_code == 200
assert response.get_json()["message"] == "Connected to HomeNet"
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "pw")
def test_missing_body_rejected(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 400
wifi_manager.connect_to_network.assert_not_called()
def test_missing_ssid_rejected(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={"password": "pw"})
assert response.status_code == 400
assert "SSID is required" in response.get_json()["message"]
wifi_manager.connect_to_network.assert_not_called()
@pytest.mark.parametrize("ssid", ["", " ", "\t"])
def test_blank_ssid_rejected(self, api_v3_client, wifi_manager, ssid):
response = api_v3_client.post(self.URL, json={"ssid": ssid})
assert response.status_code == 400
wifi_manager.connect_to_network.assert_not_called()
def test_ssid_is_trimmed(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "ok")
api_v3_client.post(self.URL, json={"ssid": " HomeNet "})
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "")
def test_missing_password_becomes_empty_string(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "ok")
api_v3_client.post(self.URL, json={"ssid": "OpenNet"})
wifi_manager.connect_to_network.assert_called_once_with("OpenNet", "")
def test_null_password_becomes_empty_string(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "ok")
api_v3_client.post(self.URL, json={"ssid": "OpenNet", "password": None})
wifi_manager.connect_to_network.assert_called_once_with("OpenNet", "")
def test_failure_reports_the_managers_reason(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (False, "Bad password")
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
assert response.status_code == 400
assert response.get_json()["message"] == "Bad password"
def test_failure_without_reason_uses_fallback_text(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (False, None)
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
assert response.status_code == 400
assert response.get_json()["message"] == "Failed to connect to network"
def test_manager_exception_is_a_500_without_leaking_internals(
self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.side_effect = RuntimeError(
"/usr/lib/secret/path blew up")
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
assert response.status_code == 500
body = response.get_json()
assert body["message"] == "An error occurred; see logs for details"
# `details` comes from describe_exception, which is deliberately
# safe to return (redacted, capped) — it names the type.
assert "RuntimeError" in body["details"]
class TestDisconnect:
URL = "/api/v3/wifi/disconnect"
def test_success(self, api_v3_client, wifi_manager):
wifi_manager.disconnect_from_network.return_value = (True, "Disconnected")
response = api_v3_client.post(self.URL)
assert response.status_code == 200
assert response.get_json()["message"] == "Disconnected"
def test_failure(self, api_v3_client, wifi_manager):
wifi_manager.disconnect_from_network.return_value = (False, "Not connected")
response = api_v3_client.post(self.URL)
assert response.status_code == 400
assert response.get_json()["message"] == "Not connected"
def test_failure_without_reason_uses_fallback(self, api_v3_client, wifi_manager):
wifi_manager.disconnect_from_network.return_value = (False, "")
response = api_v3_client.post(self.URL)
assert response.get_json()["message"] == "Failed to disconnect from network"
def test_exception_is_a_500(self, api_v3_client, wifi_manager):
wifi_manager.disconnect_from_network.side_effect = OSError("nmcli missing")
assert api_v3_client.post(self.URL).status_code == 500
class TestApMode:
ENABLE = "/api/v3/wifi/ap/enable"
DISABLE = "/api/v3/wifi/ap/disable"
def test_enable_success(self, api_v3_client, wifi_manager):
wifi_manager.enable_ap_mode.return_value = (True, "AP enabled")
response = api_v3_client.post(self.ENABLE, json={})
assert response.status_code == 200
wifi_manager.enable_ap_mode.assert_called_once_with(force=False)
@pytest.mark.parametrize("raw,expected", [
(True, True), (False, False),
("true", True), ("TRUE", True), ("1", True),
("false", False), ("no", False), ("yes", False),
(1, False), # only real True or the listed strings count
])
def test_force_coercion(self, api_v3_client, wifi_manager, raw, expected):
wifi_manager.enable_ap_mode.return_value = (True, "ok")
api_v3_client.post(self.ENABLE, json={"force": raw})
wifi_manager.enable_ap_mode.assert_called_once_with(force=expected)
def test_enable_without_body(self, api_v3_client, wifi_manager):
wifi_manager.enable_ap_mode.return_value = (True, "ok")
assert api_v3_client.post(self.ENABLE).status_code == 200
def test_enable_failure(self, api_v3_client, wifi_manager):
wifi_manager.enable_ap_mode.return_value = (False, "hostapd missing")
response = api_v3_client.post(self.ENABLE, json={})
assert response.status_code == 400
assert response.get_json()["message"] == "hostapd missing"
def test_disable_success(self, api_v3_client, wifi_manager):
wifi_manager.disable_ap_mode.return_value = (True, "AP disabled")
assert api_v3_client.post(self.DISABLE).status_code == 200
def test_disable_failure(self, api_v3_client, wifi_manager):
wifi_manager.disable_ap_mode.return_value = (False, "not running")
assert api_v3_client.post(self.DISABLE).status_code == 400
def test_enable_exception_is_a_500(self, api_v3_client, wifi_manager):
wifi_manager.enable_ap_mode.side_effect = RuntimeError("boom")
assert api_v3_client.post(self.ENABLE, json={}).status_code == 500
class TestRadio:
URL = "/api/v3/wifi/radio"
def test_get_state(self, api_v3_client, wifi_manager):
wifi_manager.get_wifi_radio_state.return_value = {
"enabled": True, "ethernet_connected": False}
response = api_v3_client.get(self.URL)
assert response.status_code == 200
assert response.get_json()["data"]["enabled"] is True
def test_get_state_exception_is_a_500(self, api_v3_client, wifi_manager):
wifi_manager.get_wifi_radio_state.side_effect = OSError("rfkill missing")
assert api_v3_client.get(self.URL).status_code == 500
def test_enabled_is_required(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 400
assert "enabled is required" in response.get_json()["message"]
wifi_manager.set_wifi_radio.assert_not_called()
def test_enable_success(self, api_v3_client, wifi_manager):
wifi_manager.set_wifi_radio.return_value = (True, "Radio on", None)
wifi_manager.get_wifi_radio_state.return_value = {"enabled": True}
response = api_v3_client.post(self.URL, json={"enabled": True})
assert response.status_code == 200
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=False)
@pytest.mark.parametrize("raw,expected", [
(True, True), ("true", True), ("1", True), ("yes", True),
(False, False), ("false", False), ("off", False), (0, False),
])
def test_enabled_coercion_is_string_aware(
self, api_v3_client, wifi_manager, raw, expected):
# bool("false") is True, so the endpoint parses strings explicitly
# rather than trusting truthiness — it is a public contract, not
# only the shipped UI which always sends real JSON booleans.
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
wifi_manager.get_wifi_radio_state.return_value = {}
api_v3_client.post(self.URL, json={"enabled": raw})
wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False)
def test_force_passed_through(self, api_v3_client, wifi_manager):
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
wifi_manager.get_wifi_radio_state.return_value = {}
api_v3_client.post(self.URL, json={"enabled": False, "force": "true"})
wifi_manager.set_wifi_radio.assert_called_once_with(False, force=True)
def test_refusal_reports_reason(self, api_v3_client, wifi_manager):
# Disabling the radio without Ethernet would lock the user out of
# this very interface, so the manager can refuse with a reason.
wifi_manager.set_wifi_radio.return_value = (
False, "Refusing: no wired fallback", "no_ethernet")
response = api_v3_client.post(self.URL, json={"enabled": False})
assert response.status_code == 400
body = response.get_json()
assert body["reason"] == "no_ethernet"
assert "Refusing" in body["message"]
def test_exception_is_a_500(self, api_v3_client, wifi_manager):
wifi_manager.set_wifi_radio.side_effect = RuntimeError("boom")
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
class TestNoRealNetworking:
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
# Guard against a future refactor moving the import to module level,
# where the fixture's patch of the definition site would stop
# applying and the tests would start driving real networking.
with patch("src.wifi_manager.WiFiManager") as cls:
cls.return_value.disconnect_from_network.return_value = (True, "ok")
api_v3_client.post("/api/v3/wifi/disconnect")
assert cls.called
+365
View File
@@ -0,0 +1,365 @@
"""
Tests for src/base_odds_manager.py (BaseOddsManager).
Covers get_odds validation/caching/URL construction, the null-safe
_extract_espn_data fix (ESPN sends explicit JSON nulls for absent sides),
the no_odds sentinel, stale-cache fallback on request failure,
is_odds_available's ML-blind truth table, the fixed format_odds_summary
gate (money-line-only odds now format), get_odds_for_games, and
configuration loading.
No real network: requests.Session.get is always patched. The odds path sends
its requests through a session so it can identify itself to ESPN, so patching
the module-level requests.get would no longer intercept anything.
"""
from unittest.mock import MagicMock, patch
import pytest
import requests
from src.base_odds_manager import BaseOddsManager
FULL_ITEM = {
'details': 'DAL -3.5',
'overUnder': 47.5,
'spread': -3.5,
'homeTeamOdds': {'moneyLine': -150, 'current': {'pointSpread': {'value': -3.5}}},
'awayTeamOdds': {'moneyLine': 130, 'current': {'pointSpread': {'value': 3.5}}},
}
FULL_EXTRACTED = {
'details': 'DAL -3.5',
'over_under': 47.5,
'spread': -3.5,
'home_team_odds': {'money_line': -150, 'spread_odds': -3.5},
'away_team_odds': {'money_line': 130, 'spread_odds': 3.5},
}
def _make_response(payload):
response = MagicMock()
response.json.return_value = payload
response.raise_for_status.return_value = None
return response
@pytest.fixture
def cache_manager():
cm = MagicMock()
# A bare MagicMock returns truthy Mocks from every call, so every
# get_odds() would look like a cache hit. Explicitly wire a miss.
cm.get_with_auto_strategy.return_value = None
return cm
@pytest.fixture
def manager(cache_manager):
return BaseOddsManager(cache_manager)
@pytest.fixture
def mock_get():
with patch('src.base_odds_manager.requests.Session.get') as m:
m.return_value = _make_response({'items': [dict(FULL_ITEM)]})
yield m
# ---------------------------------------------------------------------------
# get_odds
# ---------------------------------------------------------------------------
class TestGetOdds:
def test_none_sport_raises(self, manager):
with pytest.raises(ValueError):
manager.get_odds(None, 'nfl', '1')
def test_none_league_raises(self, manager):
with pytest.raises(ValueError):
manager.get_odds('football', None, '1')
def test_cache_key_and_url(self, manager, cache_manager, mock_get):
manager.get_odds('football', 'nfl', '401')
cache_manager.get_with_auto_strategy.assert_called_once_with(
'odds_espn_football_nfl_401')
url = mock_get.call_args[0][0]
# Event id appears twice: /events/<id>/competitions/<id>/odds
assert '/events/401/competitions/401/odds' in url
assert url == ('https://sports.core.api.espn.com/v2/sports/football/'
'leagues/nfl/events/401/competitions/401/odds')
# The number matters less than the property: a single stalled request
# must not be able to consume the plugin executor's 30s operation
# budget, since odds are fetched per live game inside update().
assert mock_get.call_args.kwargs['timeout'] == 5
assert mock_get.call_args.kwargs['timeout'] < 30
def test_ncaa_fb_maps_to_college_football(self, manager, mock_get):
manager.get_odds('football', 'ncaa_fb', '401')
url = mock_get.call_args[0][0]
assert '/leagues/college-football/' in url
def test_unknown_league_passes_through(self, manager, mock_get):
manager.get_odds('football', 'xfl', '401')
assert '/leagues/xfl/' in mock_get.call_args[0][0]
def test_cache_hit_skips_http(self, manager, cache_manager, mock_get):
cache_manager.get_with_auto_strategy.return_value = {'spread': -3.0}
result = manager.get_odds('football', 'nfl', '401')
assert result == {'spread': -3.0}
mock_get.assert_not_called()
def test_cached_no_odds_sentinel_returned_verbatim(
self, manager, cache_manager, mock_get):
cache_manager.get_with_auto_strategy.return_value = {'no_odds': True}
result = manager.get_odds('football', 'nfl', '401')
assert result == {'no_odds': True}
mock_get.assert_not_called()
assert manager.is_odds_available(result) is False
def test_success_caches_extracted_data_with_interval_ttl(
self, manager, cache_manager, mock_get):
result = manager.get_odds('football', 'nfl', '401',
update_interval_seconds=100)
assert result == FULL_EXTRACTED
cache_manager.set.assert_called_once_with(
'odds_espn_football_nfl_401', FULL_EXTRACTED, ttl=100)
def test_no_odds_caches_sentinel(self, manager, cache_manager, mock_get):
mock_get.return_value = _make_response({'count': 0, 'items': []})
result = manager.get_odds('football', 'nfl', '401')
assert result is None
cache_manager.set.assert_called_once_with(
'odds_espn_football_nfl_401', {'no_odds': True}, ttl=3600)
def test_zero_interval_falls_back_to_default(
self, manager, cache_manager, mock_get):
# Quirk pin: `update_interval_seconds or self.update_interval`
# treats an explicit 0 as falsy, so the 3600 default wins.
manager.get_odds('football', 'nfl', '401', update_interval_seconds=0)
assert cache_manager.set.call_args.kwargs['ttl'] == 3600
def test_request_exception_falls_back_to_stale_cache(
self, manager, cache_manager, mock_get):
cache_manager.get_with_auto_strategy.side_effect = [
None, {'stale': True}]
mock_get.side_effect = requests.exceptions.RequestException('boom')
result = manager.get_odds('football', 'nfl', '401')
assert result == {'stale': True}
assert cache_manager.get_with_auto_strategy.call_count == 2
# ---------------------------------------------------------------------------
# _extract_espn_data
# ---------------------------------------------------------------------------
class TestExtractEspnData:
def test_full_item_extracts_all_fields(self, manager):
result = manager._extract_espn_data({'items': [dict(FULL_ITEM)]})
assert result == FULL_EXTRACTED
def test_explicit_nulls_do_not_raise(self, manager):
# Post-fix: ESPN sends explicit JSON nulls for absent sides
# ("homeTeamOdds": null, "current": null); extraction must not
# raise and yields None fields.
payload = {'items': [{
'homeTeamOdds': None,
'awayTeamOdds': {'moneyLine': 150, 'current': None},
}]}
result = manager._extract_espn_data(payload)
assert result is not None
assert result['home_team_odds']['money_line'] is None
assert result['home_team_odds']['spread_odds'] is None
assert result['away_team_odds']['money_line'] == 150
assert result['away_team_odds']['spread_odds'] is None
def test_valid_empty_response_returns_none(self, manager):
assert manager._extract_espn_data({'count': 0, 'items': []}) is None
def test_unexpected_structure_returns_none(self, manager):
assert manager._extract_espn_data({'unexpected': True}) is None
def test_item_without_odds_fields_cached_as_data_not_sentinel(
self, manager, cache_manager, mock_get):
# Characterization pin: an item with no odds fields still extracts
# to a truthy dict of all-None values, so get_odds caches it as
# real data (NOT the no_odds sentinel) — but is_odds_available
# correctly reports False for it.
mock_get.return_value = _make_response({'items': [{}]})
result = manager.get_odds('football', 'nfl', '401')
assert result == {
'details': None,
'over_under': None,
'spread': None,
'home_team_odds': {'money_line': None, 'spread_odds': None},
'away_team_odds': {'money_line': None, 'spread_odds': None},
}
cache_manager.set.assert_called_once_with(
'odds_espn_football_nfl_401', result, ttl=3600)
assert manager.is_odds_available(result) is False
# ---------------------------------------------------------------------------
# is_odds_available
# ---------------------------------------------------------------------------
class TestIsOddsAvailable:
def test_none_is_false(self, manager):
assert manager.is_odds_available(None) is False
def test_empty_dict_is_false(self, manager):
assert manager.is_odds_available({}) is False
def test_no_odds_sentinel_is_false(self, manager):
assert manager.is_odds_available({'no_odds': True}) is False
def test_spread_is_true(self, manager):
assert manager.is_odds_available({'spread': -3.5}) is True
def test_over_under_is_true(self, manager):
assert manager.is_odds_available({'over_under': 47.5}) is True
def test_nested_home_spread_odds_is_true(self, manager):
assert manager.is_odds_available(
{'home_team_odds': {'spread_odds': -3.5}}) is True
def test_nested_away_spread_odds_is_true(self, manager):
assert manager.is_odds_available(
{'away_team_odds': {'spread_odds': 3.5}}) is True
def test_moneyline_only_is_false(self, manager):
# Pinned ML-blind contract: is_odds_available ignores money lines
# (its callers decide whether to render an odds widget). Note that
# format_odds_summary deliberately uses a DIFFERENT gate — it will
# still format money-line-only odds (see TestFormatOddsSummary).
ml_only = {
'home_team_odds': {'money_line': -120},
'away_team_odds': {'money_line': 100},
}
assert manager.is_odds_available(ml_only) is False
# ---------------------------------------------------------------------------
# format_odds_summary (fixed gate: empty / no_odds only)
# ---------------------------------------------------------------------------
class TestFormatOddsSummary:
def test_moneyline_only_formats(self, manager):
result = manager.format_odds_summary({
'home_team_odds': {'money_line': -120},
'away_team_odds': {'money_line': 100},
})
assert result == 'Home ML: -120 | Away ML: 100'
def test_full_data_formats_all_parts(self, manager):
result = manager.format_odds_summary(FULL_EXTRACTED)
assert result == 'Spread: -3.5 | O/U: 47.5 | Home ML: -150 | Away ML: 130'
def test_none_is_no_odds(self, manager):
assert manager.format_odds_summary(None) == 'No odds available'
def test_empty_dict_is_no_odds(self, manager):
assert manager.format_odds_summary({}) == 'No odds available'
def test_no_odds_sentinel_is_no_odds(self, manager):
assert manager.format_odds_summary(
{'no_odds': True}) == 'No odds available'
# ---------------------------------------------------------------------------
# get_odds_for_games
# ---------------------------------------------------------------------------
class TestGetOddsForGames:
def test_missing_fields_get_none_odds_without_http(self, manager, mock_get):
games = [
{'sport': 'football'},
{'league': 'nfl'},
{'id': '9'},
{},
]
result = manager.get_odds_for_games(games)
assert all(g['odds'] is None for g in result)
mock_get.assert_not_called()
def test_per_game_exception_continues_loop(self, manager, monkeypatch):
def fake_get_odds(sport, league, event_id,
update_interval_seconds=None):
if event_id == 'bad':
raise RuntimeError('boom')
return {'spread': -1.0}
monkeypatch.setattr(manager, 'get_odds', fake_get_odds)
games = [
{'sport': 'football', 'league': 'nfl', 'id': 'bad'},
{'sport': 'football', 'league': 'nfl', 'id': 'ok'},
]
result = manager.get_odds_for_games(games)
assert len(result) == 2
assert result[0]['odds'] is None
assert result[1]['odds'] == {'spread': -1.0}
def test_input_dicts_mutated_in_place_and_returned(self, manager, mock_get):
# Pin: get_odds_for_games mutates the caller's game dicts in place
# and returns the same objects, not copies.
game = {'sport': 'football', 'league': 'nfl', 'id': '401'}
result = manager.get_odds_for_games([game])
assert result[0] is game
assert game['odds'] == FULL_EXTRACTED
# ---------------------------------------------------------------------------
# _load_configuration
# ---------------------------------------------------------------------------
class TestLoadConfiguration:
def test_loads_values_from_config(self, cache_manager):
config_manager = MagicMock()
config_manager.get_config.return_value = {
'base_odds_manager': {
'update_interval': 100,
'timeout': 5,
'cache_ttl': 42,
}
}
manager = BaseOddsManager(cache_manager, config_manager=config_manager)
assert manager.update_interval == 100
# Key/attr mismatch pin: the config key is 'timeout' but the
# attribute is request_timeout.
assert manager.request_timeout == 5
assert manager.cache_ttl == 42
def test_get_config_raising_keeps_defaults(self, cache_manager):
config_manager = MagicMock()
config_manager.get_config.side_effect = RuntimeError('boom')
manager = BaseOddsManager(cache_manager, config_manager=config_manager)
assert manager.update_interval == 3600
assert manager.request_timeout == 5
assert manager.cache_ttl == 1800
+40 -4
View File
@@ -78,10 +78,20 @@ class TestInstanceVariable:
instance_duration=[30])
assert plugin.get_display_duration() == 20.0
def test_bool_true_is_one_second(self):
# Characterized quirk: bool is an int subclass, so display_duration =
# True passes the isinstance((int, float)) branch and returns 1.0.
assert make_plugin(instance_duration=True).get_display_duration() == 1.0
def test_bool_true_falls_through_like_any_non_number(self):
# bool is an int subclass, but a boolean is not a duration: True
# must NOT read as 1 second — it falls through to config/default.
assert make_plugin(instance_duration=True).get_display_duration() == 15.0
def test_bool_true_falls_through_to_config(self):
plugin = make_plugin(config={"display_duration": 20},
instance_duration=True)
assert plugin.get_display_duration() == 20.0
def test_bool_false_still_falls_through(self):
plugin = make_plugin(config={"display_duration": 20},
instance_duration=False)
assert plugin.get_display_duration() == 20.0
class TestConfigFallback:
@@ -108,3 +118,29 @@ class TestConfigFallback:
def test_config_none_uses_default(self):
assert make_plugin({"display_duration": None}).get_display_duration() == 15.0
def test_config_bool_uses_default(self):
assert make_plugin({"display_duration": True}).get_display_duration() == 15.0
assert make_plugin({"display_duration": False}).get_display_duration() == 15.0
class TestValidateConfigDuration:
# validate_config must agree with get_display_duration about what a
# valid duration is — a config it accepts must not then be rejected
# (or silently defaulted) when the duration is actually read.
def test_positive_number_valid(self):
assert make_plugin({"display_duration": 20}).validate_config() is True
def test_zero_and_negative_invalid(self):
assert make_plugin({"display_duration": 0}).validate_config() is False
assert make_plugin({"display_duration": -5}).validate_config() is False
def test_bool_invalid(self):
# bool is an int subclass; True would otherwise pass as "positive
# number" here while get_display_duration rejects it.
assert make_plugin({"display_duration": True}).validate_config() is False
assert make_plugin({"display_duration": False}).validate_config() is False
def test_missing_duration_valid(self):
assert make_plugin({}).validate_config() is True
+146
View File
@@ -0,0 +1,146 @@
"""Tests that one cache directory gets one cleanup thread per process.
The sweep lists a directory and deletes from it, so a second thread over the
same directory only duplicates the scan. Nothing enforced that: every
CacheManager started its own, and since the loop closes over `self`, a
discarded manager could never be collected -- its thread stayed alive and
re-scanned the same directory every 24 hours for the life of the process.
On the dev rig a display process carried three, for one cache directory:
14:22:59.954 display_controller (the real one)
14:22:59.973 startup validation, run 1 (discarded)
14:23:01.055 startup validation, run 2 (discarded)
Startup validation runs twice and built a throwaway manager each time, purely
to read a directory path.
"""
import threading
import pytest
from src.cache_manager import CacheManager
@pytest.fixture(autouse=True)
def _clean_registry():
CacheManager._cleanup_owners.clear()
yield
for owner in list(CacheManager._cleanup_owners.values()):
owner.stop_cleanup_thread()
CacheManager._cleanup_owners.clear()
def _live_cleanup_threads():
return [t for t in threading.enumerate()
if t.name == 'DiskCacheCleanup' and t.is_alive()]
@pytest.fixture
def manager(tmp_path, monkeypatch):
"""A CacheManager pinned to a temp dir, so tests never touch the real one."""
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
lambda self: str(tmp_path))
return CacheManager
class TestOneThreadPerDirectory:
def test_a_single_manager_starts_one(self, manager):
before = len(_live_cleanup_threads())
m = manager()
try:
assert len(_live_cleanup_threads()) == before + 1
finally:
m.stop_cleanup_thread()
def test_three_managers_still_start_one(self, manager):
# Exactly the rig's shape: the real manager plus two throwaways.
before = len(_live_cleanup_threads())
managers = [manager() for _ in range(3)]
try:
assert len(_live_cleanup_threads()) == before + 1
finally:
for m in managers:
m.stop_cleanup_thread()
def test_the_first_one_owns_it(self, manager):
first, second = manager(), manager()
try:
assert CacheManager._cleanup_owners[first.cache_dir] is first
assert second._cleanup_thread is None
finally:
first.stop_cleanup_thread()
second.stop_cleanup_thread()
def test_the_survivor_can_take_over(self, manager):
first = manager()
first.stop_cleanup_thread()
assert not _live_cleanup_threads()
second = manager()
try:
# Ownership was released, so the directory is swept again rather
# than being left permanently unclaimed by a dead owner.
assert len(_live_cleanup_threads()) == 1
assert CacheManager._cleanup_owners[second.cache_dir] is second
finally:
second.stop_cleanup_thread()
def test_stopping_a_non_owner_does_not_unclaim_the_directory(self, manager):
first, second = manager(), manager()
try:
second.stop_cleanup_thread() # never owned it
assert CacheManager._cleanup_owners[first.cache_dir] is first
assert len(_live_cleanup_threads()) == 1
finally:
first.stop_cleanup_thread()
def test_separate_directories_get_separate_threads(self, tmp_path, monkeypatch):
a, b = tmp_path / 'a', tmp_path / 'b'
a.mkdir()
b.mkdir()
dirs = iter([str(a), str(b)])
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
lambda self: next(dirs))
first, second = CacheManager(), CacheManager()
try:
assert first.cache_dir != second.cache_dir
assert len(_live_cleanup_threads()) == 2
finally:
first.stop_cleanup_thread()
second.stop_cleanup_thread()
def test_no_thread_leaks_across_many_constructions(self, manager):
before = len(_live_cleanup_threads())
made = [manager() for _ in range(12)]
try:
assert len(_live_cleanup_threads()) == before + 1
finally:
for m in made:
m.stop_cleanup_thread()
assert len(_live_cleanup_threads()) == before
class TestValidatorDoesNotBuildItsOwn:
def test_it_uses_the_cache_manager_it_is_given(self, manager):
from src.startup_validator import StartupValidator
shared = manager()
try:
before = len(_live_cleanup_threads())
v = StartupValidator(config_manager=object(), cache_manager=shared)
v._validate_cache_directory()
assert len(_live_cleanup_threads()) == before, (
"validation started another cleanup thread")
finally:
shared.stop_cleanup_thread()
def test_without_one_it_cleans_up_after_itself(self, manager):
from src.startup_validator import StartupValidator
before = len(_live_cleanup_threads())
v = StartupValidator(config_manager=object())
v._validate_cache_directory()
assert len(_live_cleanup_threads()) == before, (
"the fallback manager left its cleanup thread running")
+188
View File
@@ -0,0 +1,188 @@
"""Tests that abandoned cache temp files get collected.
DiskCache.set() writes through tempfile.mkstemp and os.replace, removing its
own temp file in a finally. That covers a failed write, but not a process that
dies between the two -- a SIGKILL, a lost restart race, a power cut, all
ordinary on a Pi. Nothing collected what was left behind: the temp names are
".<key>.json.<random>", and the expiry sweep only listed names ending in
.json, so they accumulated for as long as the card had been in service.
Measured on a live rig before this fix: 76 orphans totalling 1,050 MB -- 81%
of the entire cache directory -- the oldest six months old.
The predicate that decides what to delete is tested harder than the sweep
itself, because a false positive here destroys real data.
"""
import os
import time
import pytest
from src.cache.disk_cache import DiskCache, _ORPHAN_TEMP_MAX_AGE_SECONDS
class FakeStrategy:
@staticmethod
def get_data_type_from_key(key):
return 'default'
POLICIES = {'default': 30}
@pytest.fixture
def cache(tmp_path):
return DiskCache(str(tmp_path))
def _age(path, seconds):
old = time.time() - seconds
os.utime(path, (old, old))
def _write(tmp_path, name, body='{}'):
p = tmp_path / name
p.write_text(body, encoding='utf-8')
return p
class TestWhatCountsAsAnOrphan:
@pytest.mark.parametrize('name', [
'.weather.json.a1b2c3d4',
'.odds_espn_football_nfl_401.json.xyz00000',
'.a.json.b',
])
def test_our_temp_files_are_orphans(self, name):
assert DiskCache._is_orphaned_temp(name)
@pytest.mark.parametrize('name', [
'weather.json', # real data
'.weather.json', # a dotted key that completed
'.gitignore', # not ours
'.hidden', # not ours
'weather.json.bak', # no leading dot: someone else's
'.json.abc', # no key between the dot and .json.
'.weather.json.', # no random component
'notes.txt',
])
def test_everything_else_is_left_alone(self, name):
assert not DiskCache._is_orphaned_temp(name)
def test_the_names_set_actually_creates_are_matched(self, cache, tmp_path):
"""Guard against the predicate and the writer drifting apart."""
created = []
real = os.replace
def capture(src, dst):
created.append(os.path.basename(src))
return real(src, dst)
import src.cache.disk_cache as mod
mod.os.replace = capture
try:
cache.set('weather', {'v': 1})
finally:
mod.os.replace = real
assert created, "set() did not go through the temp-file path"
assert all(DiskCache._is_orphaned_temp(n) for n in created), created
class TestTheSweep:
def test_an_old_orphan_is_removed(self, cache, tmp_path):
p = _write(tmp_path, '.weather.json.a1b2c3d4', 'x' * 5000)
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert not p.exists()
assert stats['orphan_temp_files_deleted'] == 1
assert stats['space_freed_bytes'] >= 5000
def test_an_in_flight_write_is_not_snatched_away(self, cache, tmp_path):
# The whole risk of this sweep: deleting a temp file another thread is
# about to os.replace into place.
p = _write(tmp_path, '.weather.json.inflight')
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert p.exists()
def test_real_cache_files_survive(self, cache, tmp_path):
fresh = _write(tmp_path, 'weather.json')
dotted = _write(tmp_path, '.weather.json')
_age(dotted, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert fresh.exists()
assert dotted.exists(), "a completed .json was treated as a temp file"
def test_unrelated_dotfiles_survive(self, cache, tmp_path):
keep = _write(tmp_path, '.gitignore')
_age(keep, 400 * 86400)
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert keep.exists()
def test_expiry_still_works_alongside_it(self, cache, tmp_path):
stale = _write(tmp_path, 'old.json')
_age(stale, 40 * 86400) # past the 30-day default
orphan = _write(tmp_path, '.old.json.zz999999')
_age(orphan, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert not stale.exists()
assert not orphan.exists()
assert stats['files_deleted'] == 2
assert stats['orphan_temp_files_deleted'] == 1
def test_the_rig_scenario(self, cache, tmp_path):
"""76 orphans of assorted ages, none of them reachable before."""
for i in range(76):
p = _write(tmp_path, '.sched_%d.json.r%06d' % (i, i), 'x' * 1000)
_age(p, (i + 2) * 86400)
keep = _write(tmp_path, 'sched.json')
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert stats['orphan_temp_files_deleted'] == 76
assert keep.exists()
assert not list(tmp_path.glob('.sched_*'))
# The summary line is "<deleted>/<scanned>", so an orphan that is
# deleted but never counted as scanned renders as "76/1".
assert stats['files_scanned'] == 77
assert stats['files_deleted'] <= stats['files_scanned']
def test_deleted_never_exceeds_scanned(self, cache, tmp_path):
p = _write(tmp_path, '.only.json.a1b2c3d4')
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert stats['files_deleted'] == 1
assert stats['files_scanned'] == 1
def test_a_missing_file_mid_sweep_is_not_an_error(self, cache, tmp_path):
p = _write(tmp_path, '.weather.json.a1b2c3d4')
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
import src.cache.disk_cache as mod
real = mod.os.path.getsize
def vanish(path):
if path.endswith('.a1b2c3d4'):
os.remove(path)
raise FileNotFoundError(path)
return real(path)
mod.os.path.getsize = vanish
try:
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
finally:
mod.os.path.getsize = real
assert stats['errors'] == 0
+130
View File
@@ -0,0 +1,130 @@
"""Tests that a per-entry ttl actually controls expiry.
Regression under test: `CacheManager.set(key, data, ttl=...)` stored the value
and no read path ever consulted it. Expiry came from a `max_age` inferred from
substrings in the key ("live", "odds", "stock"), so every caller passing `ttl=`
-- 48 sites across the plugins and 4 in the core -- was writing a number that
did nothing. The old docstring admitted as much: "stored for compatibility but
expiration is still controlled via max_age when reading".
Measured against a real device's cache (8,873 entries carrying a ttl), the
inferred value and the intended one disagreed almost everywhere:
stocks max_age 600 vs ttl 1800 4903 entries
news max_age 3600 vs ttl 600 1770 entries
odds max_age 1800 vs ttl 3600 1301 entries
images max_age 300 vs ttl 2592000 20 entries
No `sports_live` entry carries a ttl, so live scores keep their inferred
30-second freshness either way.
"""
import time
import pytest
from src.cache.memory_cache import MemoryCache
from src.cache.disk_cache import DiskCache
@pytest.fixture
def disk(tmp_path):
return DiskCache(cache_dir=str(tmp_path))
def _record(ttl=None, age=0.0):
rec = {"data": {"v": 1}, "timestamp": time.time() - age}
if ttl is not None:
rec["ttl"] = ttl
return rec
class TestDiskCacheHonoursTtl:
def test_ttl_longer_than_max_age_keeps_the_entry(self, disk):
# The odds case: written wanting an hour, expired at 30 minutes.
disk.set("odds_espn_football_nfl_401", _record(ttl=3600, age=1900))
assert disk.get("odds_espn_football_nfl_401", max_age=1800) is not None
def test_ttl_shorter_than_max_age_expires_the_entry(self, disk):
# The news case: written wanting 10 minutes, kept for an hour.
disk.set("news_NHL_1", _record(ttl=600, age=900))
assert disk.get("news_NHL_1", max_age=3600) is None
def test_without_a_ttl_max_age_still_applies(self, disk):
disk.set("plain_key", _record(age=400))
assert disk.get("plain_key", max_age=300) is None
disk.set("plain_key2", _record(age=100))
assert disk.get("plain_key2", max_age=300) is not None
def test_a_fresh_entry_within_its_ttl_survives(self, disk):
disk.set("k", _record(ttl=600, age=10))
assert disk.get("k", max_age=30) is not None
def test_ttl_zero_expires_immediately(self, disk):
# 0 means zero seconds, not "forever" -- max_age=None is how a caller
# asks for no expiry.
disk.set("k", _record(ttl=0, age=1))
assert disk.get("k", max_age=99999) is None
@pytest.mark.parametrize("bad", ["600", None, True, False, -5, {"a": 1}])
def test_a_nonsense_ttl_falls_back_to_max_age(self, disk, bad):
# Including bools: True is an int in Python and must not become a 1s ttl.
rec = _record(age=400)
rec["ttl"] = bad
disk.set("k_%s" % type(bad).__name__, rec)
assert disk.get("k_%s" % type(bad).__name__, max_age=300) is None
class TestMemoryCacheHonoursTtl:
def test_ttl_longer_than_max_age_keeps_the_entry(self):
m = MemoryCache()
m.set("k", _record(ttl=3600))
m._timestamps["k"] = time.time() - 1900
assert m.get("k", max_age=1800) is not None
def test_ttl_shorter_than_max_age_expires_the_entry(self):
m = MemoryCache()
m.set("k", _record(ttl=600))
m._timestamps["k"] = time.time() - 900
assert m.get("k", max_age=3600) is None
def test_without_a_ttl_max_age_still_applies(self):
m = MemoryCache()
m.set("k", _record())
m._timestamps["k"] = time.time() - 400
assert m.get("k", max_age=300) is None
def test_both_layers_agree(self, tmp_path):
"""A record must not be live in one layer and expired in the other."""
rec = _record(ttl=3600, age=1900)
d = DiskCache(cache_dir=str(tmp_path))
d.set("k", rec)
m = MemoryCache()
m.set("k", rec)
m._timestamps["k"] = rec["timestamp"]
assert (d.get("k", max_age=1800) is not None) == (m.get("k", max_age=1800) is not None)
class TestEndToEnd:
def test_set_then_get_respects_the_ttl(self, tmp_path, monkeypatch):
"""The behaviour a caller of CacheManager.set(ttl=...) expects."""
from src.cache_manager import CacheManager
cm = CacheManager()
cm._disk_cache_component = DiskCache(cache_dir=str(tmp_path))
cm._memory_cache_component = MemoryCache()
cm.set("odds_espn_football_nfl_401", {"spread": 6.5}, ttl=3600)
# Age the stored record past the inferred max_age for odds (1800s) but
# within the ttl the caller asked for.
path = cm._disk_cache_component.get_cache_path("odds_espn_football_nfl_401")
import json
rec = json.load(open(path))
rec["timestamp"] = time.time() - 1900
json.dump(rec, open(path, "w"))
cm._memory_cache_component.clear() if hasattr(
cm._memory_cache_component, "clear") else None
got = cm.get_with_auto_strategy("odds_espn_football_nfl_401")
assert got is not None, "the ttl the caller asked for was ignored"
+253
View File
@@ -0,0 +1,253 @@
"""
Tests for src/common/config_helper.py pins the ConfigHelper contract.
Covers: load/save round trips (missing/malformed files return {} rather
than raising, non-ASCII preserved via ensure_ascii=False, top-level JSON
lists returned as-is), dot-notation get/set including the silent-failure
contract when an intermediate key holds a non-dict, merge_configs deep
semantics with NO aliasing of the base config (the fixed bug the old
shallow copy let mutations of the merged result leak into base's nested
dicts), simplified schema validation including the caught-TypeError path
when a schema 'type' is given as a string, plugin config key conventions
('{plugin_id}_config', enabled defaults True), and required-key checks
where a key present with value None counts as present.
"""
import json
import pytest
from src.common.config_helper import ConfigHelper
@pytest.fixture
def helper():
return ConfigHelper()
class TestLoadConfig:
def test_missing_file_returns_empty_dict(self, helper, tmp_path):
assert helper.load_config(tmp_path / "nope.json") == {}
def test_malformed_json_returns_empty_dict(self, helper, tmp_path):
path = tmp_path / "bad.json"
path.write_text("{ this is not json", encoding="utf-8")
assert helper.load_config(path) == {}
def test_top_level_list_returned_as_is(self, helper, tmp_path):
# load_config does not enforce a dict shape: a JSON list comes
# straight back. Pinned as a characterization of current behavior.
path = tmp_path / "list.json"
path.write_text("[1, 2, 3]", encoding="utf-8")
assert helper.load_config(path) == [1, 2, 3]
class TestSaveConfig:
def test_round_trip(self, helper, tmp_path):
path = tmp_path / "config.json"
config = {'display': {'hardware': {'rows': 32}}, 'timezone': 'UTC'}
assert helper.save_config(config, path) is True
assert helper.load_config(path) == config
def test_creates_parent_directories(self, helper, tmp_path):
path = tmp_path / "deep" / "nested" / "config.json"
assert helper.save_config({'a': 1}, path) is True
assert path.exists()
assert helper.load_config(path) == {'a': 1}
def test_non_ascii_survives_round_trip(self, helper, tmp_path):
path = tmp_path / "config.json"
config = {'city': 'Zürich', 'note': 'météo ☀'}
assert helper.save_config(config, path) is True
assert helper.load_config(path) == config
# ensure_ascii=False: characters are written raw, not \u-escaped
assert 'Zürich' in path.read_text(encoding='utf-8')
def test_directory_path_returns_false_not_raise(self, helper, tmp_path):
assert helper.save_config({'a': 1}, tmp_path) is False
class TestGetConfigValue:
def test_dot_notation_hit(self, helper):
config = {'display': {'hardware': {'rows': 32}}}
assert helper.get_config_value(config, 'display.hardware.rows') == 32
def test_missing_returns_default(self, helper):
sentinel = object()
assert helper.get_config_value({}, 'display.rows', default=sentinel) is sentinel
def test_intermediate_non_dict_returns_default(self, helper):
config = {'display': 'not-a-dict'}
assert helper.get_config_value(config, 'display.hardware.rows', default=64) == 64
def test_required_missing_raises_keyerror(self, helper):
with pytest.raises(KeyError):
helper.get_config_value({}, 'display.rows', required=True)
class TestSetConfigValue:
def test_sets_top_level(self, helper):
config = {}
helper.set_config_value(config, 'timezone', 'UTC')
assert config == {'timezone': 'UTC'}
def test_auto_creates_intermediates(self, helper):
config = {}
helper.set_config_value(config, 'display.hardware.rows', 32)
assert config == {'display': {'hardware': {'rows': 32}}}
def test_silent_failure_on_non_dict_intermediate(self, helper):
# 'a' exists but holds an int; the assignment attempt raises
# TypeError internally, which set_config_value swallows and logs.
# The config is left unchanged — pinned silent-failure contract.
config = {'a': 5}
helper.set_config_value(config, 'a.b', 1)
assert config == {'a': 5}
class TestMergeConfigs:
def test_nested_dicts_merge_recursively(self, helper):
base = {'display': {'rows': 32, 'cols': 64}, 'timezone': 'UTC'}
override = {'display': {'cols': 128, 'brightness': 90}}
merged = helper.merge_configs(base, override)
assert merged == {
'display': {'rows': 32, 'cols': 128, 'brightness': 90},
'timezone': 'UTC',
}
def test_scalar_override_wins_over_dict(self, helper):
merged = helper.merge_configs({'display': {'rows': 32}}, {'display': 7})
assert merged['display'] == 7
def test_dict_override_wins_over_scalar(self, helper):
merged = helper.merge_configs({'display': 7}, {'display': {'rows': 32}})
assert merged['display'] == {'rows': 32}
def test_no_aliasing_of_base(self, helper):
# Post-fix: merge deep-copies base, so mutating the result never
# leaks back into the caller's base config.
base = {'display': {'x': 1}}
merged = helper.merge_configs(base, {})
assert merged['display'] is not base['display']
merged['display']['x'] = 99
assert base['display']['x'] == 1
def test_inputs_unchanged(self, helper):
base = {'a': {'b': 1}}
override = {'a': {'c': 2}}
helper.merge_configs(base, override)
assert base == {'a': {'b': 1}}
assert override == {'a': {'c': 2}}
def test_no_aliasing_of_override_values(self, helper):
# The non-recursive branch must deep-copy the override value too:
# mutating a merged-in list or dict must not reach back into
# override_config.
override = {'teams': ['A', 'B'], 'nested': {'x': [1]}}
merged = helper.merge_configs({}, override)
merged['teams'].append('C')
merged['nested']['x'].append(2)
assert override == {'teams': ['A', 'B'], 'nested': {'x': [1]}}
class TestValidateConfig:
def test_no_schema_dict_is_valid(self, helper):
assert helper.validate_config({'a': 1}) is True
def test_no_schema_list_is_invalid(self, helper):
assert helper.validate_config([1, 2]) is False
def test_required_key_missing_is_invalid(self, helper):
schema = {'rows': {'required': True, 'type': int}}
assert helper.validate_config({}, schema) is False
def test_optional_key_missing_is_valid(self, helper):
schema = {'rows': {'required': False, 'type': int}}
assert helper.validate_config({}, schema) is True
def test_wrong_type_is_invalid(self, helper):
schema = {'rows': {'type': int}}
assert helper.validate_config({'rows': 'thirty-two'}, schema) is False
assert helper.validate_config({'rows': 32}, schema) is True
def test_allowed_values_violation_is_invalid(self, helper):
schema = {'mode': {'allowed_values': ['clock', 'weather']}}
assert helper.validate_config({'mode': 'stocks'}, schema) is False
assert helper.validate_config({'mode': 'clock'}, schema) is True
def test_string_type_in_schema_is_invalid_via_typeerror(self, helper):
# 'type' given as the STRING "int" makes isinstance() raise
# TypeError; validate_config catches it and returns False rather
# than raising. Pinned characterization.
schema = {'rows': {'type': 'int'}}
assert helper.validate_config({'rows': 32}, schema) is False
class TestPluginConfigHelpers:
def test_get_plugin_config_uses_suffixed_key(self, helper):
plugin_cfg = {'enabled': True, 'display_duration': 30}
assert helper.get_plugin_config({'clock_config': plugin_cfg}, 'clock') == plugin_cfg
def test_get_plugin_config_bare_id_key_not_found(self, helper):
# Only '{plugin_id}_config' is consulted — a bare 'clock' section
# is invisible to this helper. Pinned key contract.
assert helper.get_plugin_config({'clock': {'enabled': True}}, 'clock') == {}
def test_create_default_config_wraps_in_suffixed_key(self, helper):
defaults = {'enabled': True}
assert helper.create_default_config('clock', defaults) == {'clock_config': defaults}
def test_is_plugin_enabled_defaults_true_for_unknown(self, helper):
assert helper.is_plugin_enabled({}, 'clock') is True
def test_is_plugin_enabled_false_when_disabled(self, helper):
config = {'clock_config': {'enabled': False}}
assert helper.is_plugin_enabled(config, 'clock') is False
def test_is_plugin_enabled_ignores_bare_id_key(self, helper):
# Disabled under the wrong key -> still reported enabled (default).
config = {'clock': {'enabled': False}}
assert helper.is_plugin_enabled(config, 'clock') is True
class TestSportsAndDisplayHelpers:
def test_get_display_config(self, helper):
display = {'hardware': {'rows': 32}}
assert helper.get_display_config({'display': display}) == display
assert helper.get_display_config({}) == {}
def test_get_sports_config_uses_scoreboard_suffix(self, helper):
sport_cfg = {'favorite_teams': ['TB']}
config = {'football_scoreboard': sport_cfg}
assert helper.get_sports_config(config, 'football') == sport_cfg
assert helper.get_sports_config(config, 'hockey') == {}
def test_get_favorite_teams(self, helper):
config = {'football_scoreboard': {'favorite_teams': ['TB', 'DAL']}}
assert helper.get_favorite_teams(config, 'football') == ['TB', 'DAL']
assert helper.get_favorite_teams({}, 'football') == []
def test_get_display_modes(self, helper):
modes = {'live': True, 'recent': False}
config = {'football_scoreboard': {'display_modes': modes}}
assert helper.get_display_modes(config, 'football') == modes
assert helper.get_display_modes({}, 'football') == {}
class TestValidateRequiredKeys:
def test_returns_missing_subset(self, helper):
config = {'a': 1, 'c': {'d': 2}}
missing = helper.validate_required_keys(config, ['a', 'b', 'c.d', 'c.e'])
assert missing == ['b', 'c.e']
def test_dot_notation_present(self, helper):
config = {'display': {'hardware': {'rows': 32}}}
assert helper.validate_required_keys(config, ['display.hardware.rows']) == []
def test_empty_requirements(self, helper):
assert helper.validate_required_keys({'a': 1}, []) == []
def test_present_with_none_counts_as_present(self, helper):
# _has_key checks key membership, not truthiness — a key set to
# None is NOT reported missing. Pinned semantics.
assert helper.validate_required_keys({'a': None}, ['a']) == []
+156
View File
@@ -177,3 +177,159 @@ class TestLoadFastPath:
os.utime(config_file, ns=(1_000_000_000, 1_000_000_000))
assert manager.load_config()["timezone"] == "AAA" # stale, by design
class TestArraySecretStripAndMerge:
"""Array-item secrets round-trip (parallel-placeholder lists).
secret_helpers.separate_secrets emits array secrets as a list parallel
to the regular list, with {} for items that carry no secrets. Strip
must remove the secret fields from config.json while preserving item
indices; load must merge them back into the right items. The regular
list's length is authoritative in both directions.
"""
def test_strip_removes_array_item_secrets_keeps_indices(self, tmp_path):
manager = make_manager(tmp_path)
data = {"plugin": {"accounts": [
{"name": "a", "token": "ta"},
{"name": "b"},
]}}
secrets = {"plugin": {"accounts": [{"token": "ta"}, {}]}}
stripped = manager._strip_secrets_recursive(data, secrets)
assert stripped == {"plugin": {"accounts": [{"name": "a"}, {"name": "b"}]}}
def test_strip_keeps_all_placeholder_items(self, tmp_path):
# Even when every item strips to nothing extra, the list survives
# with its indices — required for merge-on-load alignment.
manager = make_manager(tmp_path)
data = {"accounts": [{"token": "t1"}, {"token": "t2"}]}
secrets = {"accounts": [{"token": "t1"}, {"token": "t2"}]}
stripped = manager._strip_secrets_recursive(data, secrets)
assert stripped == {"accounts": [{}, {}]}
def test_strip_whole_scalar_array_secret_drops_key(self, tmp_path):
# A list of secret scalars is a whole-key secret, not the parallel
# shape — the key must vanish from config.json entirely.
manager = make_manager(tmp_path)
data = {"recovery_codes": ["a", "b"], "city": "Austin"}
secrets = {"recovery_codes": ["a", "b"]}
stripped = manager._strip_secrets_recursive(data, secrets)
assert stripped == {"city": "Austin"}
def test_strip_shape_mismatch_drops_key(self, tmp_path):
# Conservative contract: if the shapes disagree, never leak.
manager = make_manager(tmp_path)
data = {"accounts": {"name": "not-a-list"}}
secrets = {"accounts": [{"token": "t"}]}
stripped = manager._strip_secrets_recursive(data, secrets)
assert stripped == {}
def test_strip_ignores_extra_secrets_entries(self, tmp_path):
# Regular list length is authoritative: a user deleted an item.
manager = make_manager(tmp_path)
data = {"accounts": [{"name": "a", "token": "ta"}]}
secrets = {"accounts": [{"token": "ta"}, {"token": "tb"}]}
stripped = manager._strip_secrets_recursive(data, secrets)
assert stripped == {"accounts": [{"name": "a"}]}
def test_merge_restores_array_item_secrets(self, tmp_path):
manager = make_manager(tmp_path)
target = {"accounts": [{"name": "a"}, {"name": "b"}]}
manager._deep_merge(target, {"accounts": [{"token": "ta"}, {}]})
assert target == {"accounts": [
{"name": "a", "token": "ta"},
{"name": "b"},
]}
def test_merge_ignores_extra_secrets_entries_with_warning(self, tmp_path, caplog):
manager = make_manager(tmp_path)
target = {"accounts": [{"name": "a"}]}
with caplog.at_level("WARNING"):
manager._deep_merge(
target, {"accounts": [{"token": "ta"}, {"token": "ghost"}]})
assert target == {"accounts": [{"name": "a", "token": "ta"}]}
assert any("longer than the config list" in r.message for r in caplog.records)
def test_merge_non_dict_item_replaced_by_secret(self, tmp_path):
# Shape drift inside the list: the secret wins for that index.
manager = make_manager(tmp_path)
target = {"accounts": ["oddball", {"name": "b"}]}
manager._deep_merge(target, {"accounts": [{"token": "ta"}, {}]})
assert target == {"accounts": [{"token": "ta"}, {"name": "b"}]}
def test_merge_whole_scalar_array_still_replaces(self, tmp_path):
# Legacy behavior preserved: a non-parallel list replaces wholesale.
manager = make_manager(tmp_path)
target = {"recovery_codes": ["old"]}
manager._deep_merge(target, {"recovery_codes": ["new1", "new2"]})
assert target == {"recovery_codes": ["new1", "new2"]}
def test_full_save_load_round_trip(self, tmp_path):
# End to end on real files: save strips array secrets out of
# config.json; load merges them back into the right items.
manager = make_manager(
tmp_path,
config={"plugin": {"accounts": [
{"name": "a", "token": "s3cret-a"},
{"name": "b", "token": "s3cret-b"},
]}},
secrets={"plugin": {"accounts": [
{"token": "s3cret-a"}, {"token": "s3cret-b"},
]}},
)
loaded = manager.load_config()
assert loaded["plugin"]["accounts"][0]["token"] == "s3cret-a"
manager.save_config(loaded)
raw = (tmp_path / "config.json").read_text()
assert "s3cret" not in raw
on_disk = json.loads(raw)
assert on_disk["plugin"]["accounts"] == [{"name": "a"}, {"name": "b"}]
# A fresh manager (constructed directly — make_manager would
# overwrite the just-saved config.json) re-merges from the secrets
# file on load.
fresh = ConfigManager(config_path=str(tmp_path / "config.json"),
secrets_path=str(tmp_path / "config_secrets.json"))
fresh.template_path = str(tmp_path / "no-template.json")
reloaded = fresh.load_config()
assert reloaded["plugin"]["accounts"] == [
{"name": "a", "token": "s3cret-a"},
{"name": "b", "token": "s3cret-b"},
]
def test_whole_item_secret_list_never_leaks_values(self, tmp_path):
# When the ENTIRE array item is secret (schema marks both key[]
# and key[].field), separate_secrets stores the full item dicts in
# the secrets file. That shape also matches the parallel-list
# discriminator — which is safe: strip drops every leaf key that
# appears in the secret item, so only empty {} skeletons (item
# count, no values) can reach config.json, and merge-on-load
# restores the full items from those skeletons.
from src.web_interface.secret_helpers import (
find_secret_fields, separate_secrets)
schema_props = {"accounts": {
"type": "array",
"items": {"type": "object", "x-secret": True, "properties": {
"id": {"type": "string"},
"token": {"type": "string", "x-secret": True},
}},
}}
paths = find_secret_fields(schema_props)
assert paths == {"accounts[]", "accounts[].token"}
full = {"accounts": [{"id": "i1", "token": "s3cret-a"},
{"id": "i2", "token": "s3cret-b"}]}
_, secrets = separate_secrets(full, paths)
assert secrets == full # whole items are secret
manager = make_manager(tmp_path)
stripped = manager._strip_secrets_recursive(full, secrets)
assert stripped == {"accounts": [{}, {}]}
raw = json.dumps(stripped)
assert "s3cret" not in raw and "i1" not in raw
manager._deep_merge(stripped, secrets)
assert stripped == full # round trip restores the items
+31 -14
View File
@@ -338,31 +338,48 @@ class TestDisplayControllerSchedule:
"""Test schedule management."""
def test_schedule_disabled(self, test_display_controller):
"""Test when schedule is disabled."""
"""schedule.enabled=False keeps the display active even outside the
configured window. (This test used to patch config_service, which
_check_schedule never reads it asserted the init default.)"""
controller = test_display_controller
schedule_config = {"schedule": {"enabled": False}}
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
controller.config['schedule'] = {
"enabled": False,
"start_time": "09:00",
"end_time": "17:00",
}
controller._schedule_checked_minute = None
controller._tz = None
controller.is_display_active = False # prove the method flips it back
with patch('src.display_controller.datetime') as mock_datetime:
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
mock_datetime.strptime = datetime.strptime
controller._check_schedule()
assert controller.is_display_active is True
def test_active_hours(self, test_display_controller):
"""Test active hours check."""
"""A time inside the window activates the display. (This test used
to patch config_service, which _check_schedule never reads it
asserted the init default.)"""
controller = test_display_controller
controller.config['schedule'] = {
"enabled": True,
"start_time": "09:00",
"end_time": "17:00",
}
controller._schedule_checked_minute = None
controller._tz = None
controller.is_display_active = False # prove the method flips it on
with patch('src.display_controller.datetime') as mock_datetime:
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
mock_datetime.now.return_value.time.return_value = datetime.strptime("12:00", "%H:%M").time()
mock_datetime.strptime = datetime.strptime
schedule_config = {
"schedule": {
"enabled": True,
"start_time": "09:00",
"end_time": "17:00"
}
}
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
controller._check_schedule()
assert controller.is_display_active is True
controller._check_schedule()
assert controller.is_display_active is True
def test_inactive_hours(self, test_display_controller):
"""Test inactive hours check."""
+277
View File
@@ -0,0 +1,277 @@
"""
Behavioral tests for DisplayController._check_schedule and
_check_dim_schedule the on/off window and night-dimming logic.
test_display_controller_optimizations.py::TestScheduleMinuteGate already
covers the once-per-minute gating; this file covers what it doesn't:
midnight-crossing windows, mode selection (global / per-day / legacy
inference), per-day disabled days, invalid time strings, unknown
timezones, boundary equality, and the transition-tracking flags.
Both methods read only self.config and a handful of instance attributes,
so a bare stub via object.__new__ (the test_display_controller_vegas_tick
pattern) is enough no managers needed.
"""
import os
from datetime import datetime
from unittest.mock import patch
import pytest
os.environ.setdefault("EMULATOR", "true")
from src.display_controller import DisplayController # noqa: E402
def make_controller(config=None, *, normal_brightness=90):
dc = object.__new__(DisplayController)
dc.config = config or {}
dc._tz = None
dc._schedule_checked_minute = None
dc.is_display_active = True
dc._was_display_active = True
dc._normal_brightness = normal_brightness
dc._dim_checked_minute = None
dc._cached_target_brightness = None
dc.is_dimmed = False
dc._was_dimmed = False
return dc
def at(time_str, day="monday"):
"""Context manager patching the controller module's clock."""
patcher = patch("src.display_controller.datetime")
mock_dt = patcher.start()
mock_dt.strptime = datetime.strptime
mock_dt.now.return_value.time.return_value = (
datetime.strptime(time_str, "%H:%M").time())
mock_dt.now.return_value.strftime.return_value.lower.return_value = day
mock_dt.now.return_value.hour = int(time_str.split(":")[0])
mock_dt.now.return_value.minute = int(time_str.split(":")[1])
return patcher
@pytest.fixture
def clock():
patchers = []
def _at(time_str, day="monday"):
patchers.append(p := at(time_str, day))
return p
yield _at
for p in patchers:
p.stop()
def check_at(dc, time_str, day="monday", clock=None):
"""Run _check_schedule at a mocked wall time, resetting the minute gate."""
dc._schedule_checked_minute = None
p = at(time_str, day)
try:
dc._check_schedule()
finally:
p.stop()
return dc.is_display_active
def dim_at(dc, time_str, day="monday"):
dc._dim_checked_minute = None
p = at(time_str, day)
try:
return dc._check_dim_schedule()
finally:
p.stop()
class TestScheduleWindows:
def _config(self, start, end, **extra):
return {"schedule": {"enabled": True, "start_time": start,
"end_time": end, **extra},
"timezone": "UTC"}
def test_same_day_window(self):
dc = make_controller(self._config("09:00", "17:00"))
assert check_at(dc, "12:00") is True
assert check_at(dc, "20:00") is False
assert check_at(dc, "08:59") is False
def test_boundaries_are_inclusive(self):
dc = make_controller(self._config("09:00", "17:00"))
assert check_at(dc, "09:00") is True # now == start
assert check_at(dc, "17:00") is True # now == end
def test_midnight_crossing_window(self):
# 21:00 -> 07:00: active late evening AND early morning, inactive
# mid-day.
dc = make_controller(self._config("21:00", "07:00"))
assert check_at(dc, "23:00") is True
assert check_at(dc, "03:00") is True
assert check_at(dc, "12:00") is False
assert check_at(dc, "21:00") is True # boundary
assert check_at(dc, "07:00") is True # boundary
def test_no_schedule_config_is_always_active(self):
dc = make_controller({"timezone": "UTC"})
dc.is_display_active = False
dc._check_schedule()
assert dc.is_display_active is True
def test_invalid_time_string_falls_back_to_active(self):
dc = make_controller(self._config("9 o'clock", "17:00"))
dc.is_display_active = False
assert check_at(dc, "03:00") is True # ValueError -> stay on
def test_unknown_timezone_falls_back_to_utc(self):
dc = make_controller({"schedule": {"enabled": True,
"start_time": "09:00",
"end_time": "17:00"},
"timezone": "Mars/Olympus_Mons"})
assert check_at(dc, "12:00") is True
import pytz
assert dc._tz is pytz.UTC
class TestScheduleModes:
DAYS = {
"monday": {"enabled": True, "start_time": "10:00",
"end_time": "18:00"},
"tuesday": {"enabled": False},
}
def test_global_mode_ignores_days(self):
dc = make_controller({"schedule": {
"enabled": True, "mode": "global",
"start_time": "09:00", "end_time": "17:00",
"days": self.DAYS}, "timezone": "UTC"})
# 09:30 is inside the global window but outside monday's per-day one.
assert check_at(dc, "09:30", day="monday") is True
def test_per_day_mode_uses_day_window(self):
dc = make_controller({"schedule": {
"enabled": True, "mode": "per-day",
"start_time": "09:00", "end_time": "17:00",
"days": self.DAYS}, "timezone": "UTC"})
assert check_at(dc, "09:30", day="monday") is False # before 10:00
assert check_at(dc, "12:00", day="monday") is True
def test_per_day_underscore_spelling_accepted(self):
dc = make_controller({"schedule": {
"enabled": True, "mode": "per_day",
"days": self.DAYS}, "timezone": "UTC"})
assert check_at(dc, "12:00", day="monday") is True
def test_legacy_no_mode_infers_per_day_from_days_config(self):
dc = make_controller({"schedule": {
"enabled": True,
"start_time": "09:00", "end_time": "17:00",
"days": self.DAYS}, "timezone": "UTC"})
assert check_at(dc, "09:30", day="monday") is False # per-day won
def test_per_day_disabled_day_turns_display_off(self):
dc = make_controller({"schedule": {
"enabled": True, "mode": "per-day",
"days": self.DAYS}, "timezone": "UTC"})
assert check_at(dc, "12:00", day="tuesday") is False
def test_per_day_missing_day_falls_back_to_global(self):
dc = make_controller({"schedule": {
"enabled": True, "mode": "per-day",
"start_time": "09:00", "end_time": "17:00",
"days": self.DAYS}, "timezone": "UTC"})
# Wednesday has no per-day entry -> global window applies.
assert check_at(dc, "09:30", day="wednesday") is True
def test_missing_enabled_key_means_enabled(self):
# Backward compat: schedules written before the enabled flag.
dc = make_controller({"schedule": {
"start_time": "09:00", "end_time": "17:00"}, "timezone": "UTC"})
assert check_at(dc, "20:00") is False
class TestScheduleTransitions:
def test_was_display_active_tracks_state(self):
dc = make_controller({"schedule": {"enabled": True,
"start_time": "09:00",
"end_time": "17:00"},
"timezone": "UTC"})
check_at(dc, "12:00")
assert dc._was_display_active is True
check_at(dc, "20:00")
assert dc._was_display_active is False
check_at(dc, "12:05")
assert dc._was_display_active is True
class TestDimSchedule:
def _config(self, start="20:00", end="07:00", **extra):
return {"dim_schedule": {"enabled": True, "start_time": start,
"end_time": end, "dim_brightness": 25,
**extra},
"timezone": "UTC"}
def test_disabled_by_default(self):
dc = make_controller({"dim_schedule": {"start_time": "20:00",
"end_time": "07:00"},
"timezone": "UTC"})
# Unlike the on/off schedule, dimming defaults to DISABLED when the
# enabled key is missing.
assert dim_at(dc, "23:00") == 90
assert dc.is_dimmed is False
def test_overnight_dim_window(self):
dc = make_controller(self._config())
assert dim_at(dc, "23:00") == 25
assert dc.is_dimmed is True
assert dim_at(dc, "03:00") == 25
assert dim_at(dc, "12:00") == 90
assert dc.is_dimmed is False
def test_dim_brightness_defaults_to_30(self):
dc = make_controller({"dim_schedule": {"enabled": True,
"start_time": "20:00",
"end_time": "07:00"},
"timezone": "UTC"})
assert dim_at(dc, "23:00") == 30
def test_inactive_display_short_circuits_undimmed(self):
dc = make_controller(self._config())
dc.is_display_active = False
dc.is_dimmed = True
assert dim_at(dc, "23:00") == 90
assert dc.is_dimmed is False
def test_per_day_mode(self):
dc = make_controller(self._config(mode="per-day", days={
"monday": {"enabled": True, "start_time": "22:00",
"end_time": "06:00"},
"tuesday": {"enabled": False},
}))
assert dim_at(dc, "23:00", day="monday") == 25
assert dim_at(dc, "21:00", day="monday") == 90 # before per-day start
assert dim_at(dc, "23:00", day="tuesday") == 90 # day disabled
assert dc.is_dimmed is False
def test_no_legacy_inference_for_dim(self):
# Unlike _check_schedule, dim mode defaults to GLOBAL even when a
# days config exists — no legacy inference.
dc = make_controller(self._config(days={
"monday": {"enabled": True, "start_time": "22:00",
"end_time": "06:00"},
}))
# 21:00 is inside the global 20:00-07:00 window but outside monday's
# per-day 22:00 start; global mode wins.
assert dim_at(dc, "21:00", day="monday") == 25
def test_invalid_time_string_returns_normal(self):
dc = make_controller(self._config(start="late"))
assert dim_at(dc, "23:00") == 90
def test_was_dimmed_tracks_transitions(self):
dc = make_controller(self._config())
dim_at(dc, "23:00")
assert dc._was_dimmed is True
dim_at(dc, "12:00")
assert dc._was_dimmed is False
+307
View File
@@ -0,0 +1,307 @@
"""Tests for src/common/display_helper.py (DisplayHelper).
Pure-PIL tests, no hardware or mocks required. Pixel assertions rely on
getbbox()/getpixel() rather than exact text pixel counts, because the
default-font metrics vary across Pillow versions.
These tests pin the FIXED behaviors on this branch:
- draw_error_message / draw_no_data_message return a rendered image
(they previously crashed with AttributeError),
- draw_scorebug_layout draws period/status/clock as one combined top
line (previously overprinted at the same y),
- draw_ticker_layout draws at x=0 (previously started at
x=display_width, i.e. entirely off-canvas -> blank frames).
"""
from PIL import Image, ImageDraw, ImageFont
from src.common.display_helper import DisplayHelper
def default_font():
return ImageFont.load_default()
def make_helper(width=128, height=32):
return DisplayHelper(width, height)
class TestCreateBaseImage:
def test_default_is_black_rgb_display_sized(self):
helper = make_helper()
img = helper.create_base_image()
assert img.size == (128, 32)
assert img.mode == 'RGB'
assert img.getpixel((0, 0)) == (0, 0, 0)
assert img.getpixel((127, 31)) == (0, 0, 0)
# Entirely black -> no bounding box in luminance
assert img.convert('L').getbbox() is None
def test_custom_background_color(self):
helper = make_helper()
img = helper.create_base_image(background_color=(10, 20, 30))
assert img.getpixel((0, 0)) == (10, 20, 30)
assert img.getpixel((64, 16)) == (10, 20, 30)
def test_mode_rgba_is_honored(self):
helper = make_helper()
img = helper.create_base_image(mode='RGBA')
assert img.mode == 'RGBA'
assert img.size == (128, 32)
class TestCreateOverlay:
def test_overlay_is_transparent_rgba(self):
helper = make_helper()
overlay = helper.create_overlay()
assert overlay.mode == 'RGBA'
assert overlay.size == (128, 32)
assert overlay.getpixel((0, 0)) == (0, 0, 0, 0)
assert overlay.getpixel((127, 31)) == (0, 0, 0, 0)
class TestCompositeImages:
def test_rgb_inputs_are_upconverted_and_result_is_rgba(self):
helper = make_helper()
base = Image.new('RGB', (128, 32), (0, 0, 0))
overlay = Image.new('RGB', (128, 32), (255, 0, 0))
result = helper.composite_images(base, overlay)
assert result.mode == 'RGBA'
assert result.size == base.size
# RGB->RGBA conversion yields a fully opaque overlay
assert result.getpixel((0, 0)) == (255, 0, 0, 255)
def test_transparent_overlay_leaves_base_visible(self):
helper = make_helper()
base = Image.new('RGB', (128, 32), (5, 6, 7))
overlay = helper.create_overlay()
result = helper.composite_images(base, overlay)
assert result.mode == 'RGBA'
assert result.getpixel((64, 16)) == (5, 6, 7, 255)
class TestScorebugLayout:
def test_full_game_data_renders(self):
helper = make_helper()
font = default_font()
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
game_data = {
'home_score': 3, 'away_score': 2,
'home_abbr': 'NYY', 'away_abbr': 'BOS',
'status_text': 'LIVE', 'period_text': 'T9', 'clock': '2:30',
}
img = helper.draw_scorebug_layout(game_data, fonts)
assert img.mode == 'RGB'
assert img.size == (128, 32)
assert img.convert('L').getbbox() is not None
def test_empty_game_data_uses_defaults_without_raising(self):
helper = make_helper()
font = default_font()
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
img = helper.draw_scorebug_layout({}, fonts)
assert img.mode == 'RGB'
assert img.size == (128, 32)
# Defaults '0'/'HOME'/'AWAY' actually render something
assert img.convert('L').getbbox() is not None
def test_empty_fonts_dict_falls_back_to_default_font(self):
# Pin: fonts={} must not raise — PIL falls back to the default
# font when font=None is passed through.
helper = make_helper()
img = helper.draw_scorebug_layout(
{'status_text': 'FINAL', 'period_text': 'Q4', 'clock': '0:00'}, {})
assert img.size == (128, 32)
assert img.convert('L').getbbox() is not None
def test_top_line_is_one_combined_centered_draw(self):
# FIXED behavior: period/status/clock are joined into a single
# top line drawn once at y=1 instead of three overprinted draws.
helper = make_helper()
calls = []
original = helper._draw_centered_text
def spy(draw, text, font, y_position):
calls.append({'text': text, 'y_position': y_position})
original(draw, text, font, y_position)
helper._draw_centered_text = spy
font = default_font()
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
helper.draw_scorebug_layout(
{'period_text': 'Q4', 'status_text': 'LIVE', 'clock': '2:30'},
fonts)
top_calls = [c for c in calls if c['y_position'] == 1]
assert len(top_calls) == 1
text = top_calls[0]['text']
assert 'Q4' in text
assert 'LIVE' in text
assert '2:30' in text
def test_no_top_line_when_all_parts_empty(self):
helper = make_helper()
calls = []
original = helper._draw_centered_text
def spy(draw, text, font, y_position):
calls.append(y_position)
original(draw, text, font, y_position)
helper._draw_centered_text = spy
font = default_font()
helper.draw_scorebug_layout({}, {'score': font, 'team': font})
assert 1 not in calls # no combined top line drawn
def test_logo_positions_bleed_off_edges(self):
# Home logo pastes at x = width - logo.width + 10 (right edge,
# bleeding off-screen right); away at x = -10 (bleeding left).
helper = make_helper()
home_logo = Image.new('RGBA', (20, 20), (0, 0, 255, 255)) # blue
away_logo = Image.new('RGBA', (20, 20), (255, 0, 0, 255)) # red
# Empty abbrs/status so text can't land on the probed pixels.
game_data = {'home_abbr': '', 'away_abbr': ''}
font = default_font()
img = helper.draw_scorebug_layout(game_data, {'score': font},
home_logo=home_logo,
away_logo=away_logo)
# center_y = 16; logos span y 6..25 -> probe y=16 at both edges.
assert img.getpixel((0, 16)) == (255, 0, 0) # away (left edge)
assert img.getpixel((127, 16)) == (0, 0, 255) # home (right edge)
# And the off-screen parts are truly clipped: image is still 128 wide
assert img.size == (128, 32)
class TestTickerLayout:
def test_frame_is_not_blank(self):
# FIXED behavior: text now starts at x=0. Previously it was drawn
# at x=display_width, entirely off-canvas, so frames were blank.
helper = make_helper()
img = helper.draw_ticker_layout('HELLO WORLD', default_font())
assert img.size == (128, 32)
assert img.mode == 'RGB'
assert img.convert('L').getbbox() is not None
def test_text_starts_at_left_edge(self):
helper = make_helper()
img = helper.draw_ticker_layout('HELLO', default_font())
bbox = img.convert('L').getbbox()
assert bbox is not None
# Text is positioned at x=0 (outline extends 1px left, clipped),
# so ink begins hugging the left edge. Allow a couple of pixels of
# slack for font-dependent left-side bearing.
assert bbox[0] <= 2
def test_scroll_speed_does_not_affect_frame(self):
# Pin: scroll_speed is accepted for API compatibility only.
helper = make_helper()
font = default_font()
img1 = helper.draw_ticker_layout('SCROLLING', font, scroll_speed=1)
img5 = helper.draw_ticker_layout('SCROLLING', font, scroll_speed=5)
assert img1.tobytes() == img5.tobytes()
def test_custom_colors(self):
helper = make_helper()
img = helper.draw_ticker_layout('X', default_font(),
background_color=(0, 0, 40),
text_color=(0, 255, 0))
assert img.getpixel((127, 0)) == (0, 0, 40) # background corner
colors = {img.getpixel((x, y))
for x in range(img.width) for y in range(img.height)}
# Text color appears somewhere (anti-aliasing may blend it, so
# check for a green-dominant pixel rather than the exact color).
assert any(g > 150 and r < 100 for (r, g, b) in colors)
class TestCenteredText:
def test_renders_centered_text_on_background(self):
helper = make_helper()
img = helper.draw_centered_text('HI', default_font(),
background_color=(0, 0, 60),
text_color=(255, 255, 0))
assert img.size == (128, 32)
assert img.convert('L').getbbox() is not None
# Corners stay pure background
assert img.getpixel((0, 0)) == (0, 0, 60)
assert img.getpixel((127, 0)) == (0, 0, 60)
assert img.getpixel((0, 31)) == (0, 0, 60)
assert img.getpixel((127, 31)) == (0, 0, 60)
class TestErrorAndNoDataMessages:
def test_draw_error_message_returns_rendered_image(self):
# FIXED behavior: used to crash with AttributeError; now returns
# a rendered image on a dark red background.
helper = make_helper()
img = helper.draw_error_message('Boom')
assert img.size == (128, 32)
assert img.mode == 'RGB'
assert img.convert('L').getbbox() is not None
assert img.getpixel((0, 0)) == (50, 0, 0) # dark red background
def test_draw_error_message_default_text(self):
helper = make_helper()
img = helper.draw_error_message()
assert img.size == (128, 32)
assert img.getpixel((127, 31)) == (50, 0, 0)
def test_draw_no_data_message_returns_rendered_image(self):
helper = make_helper()
img = helper.draw_no_data_message()
assert img.size == (128, 32)
assert img.mode == 'RGB'
assert img.convert('L').getbbox() is not None
assert img.getpixel((0, 0)) == (0, 0, 0) # black background
class TestDrawTextWithOutline:
def test_fill_color_appears_in_output(self):
helper = make_helper()
img = Image.new('RGB', (40, 20), (0, 0, 255))
draw = ImageDraw.Draw(img)
helper._draw_text_with_outline(draw, 'X', (5, 2), default_font(),
fill=(255, 0, 0))
pixels = {img.getpixel((x, y))
for x in range(img.width) for y in range(img.height)}
# Anti-aliased fonts blend edge pixels, so look for red-dominant
# (fill) and near-black (outline) pixels rather than exact colors.
assert any(r > 150 and g < 50 for (r, g, b) in pixels) # fill
assert any(max(p) < 80 for p in pixels) # outline
def test_default_fill_is_white(self):
helper = make_helper()
img = Image.new('RGB', (40, 20), (0, 0, 255))
draw = ImageDraw.Draw(img)
helper._draw_text_with_outline(draw, 'X', (5, 2), default_font())
pixels = {img.getpixel((x, y))
for x in range(img.width) for y in range(img.height)}
# White-dominant pixel present (exact white may be anti-aliased)
assert any(r > 200 and g > 200 for (r, g, b) in pixels)
class TestOrientationAndDimensions:
def test_landscape_display(self):
helper = DisplayHelper(128, 32)
assert helper.is_landscape() is True
assert helper.is_portrait() is False
def test_portrait_display(self):
helper = DisplayHelper(32, 128)
assert helper.is_portrait() is True
assert helper.is_landscape() is False
def test_square_display_is_neither(self):
# Pin: a square display is neither portrait nor landscape.
helper = DisplayHelper(64, 64)
assert helper.is_portrait() is False
assert helper.is_landscape() is False
def test_get_center_position(self):
assert DisplayHelper(128, 32).get_center_position() == (64, 16)
def test_get_center_position_floors_odd_dimensions(self):
assert DisplayHelper(65, 33).get_center_position() == (32, 16)
def test_get_display_dimensions(self):
assert DisplayHelper(128, 32).get_display_dimensions() == (128, 32)
assert DisplayHelper(64, 64).get_display_dimensions() == (64, 64)
+42
View File
@@ -237,3 +237,45 @@ class TestDisplayManagerDoubleSided:
suppress_test_pattern=True)
assert dm.set_brightness(70) is True
assert mock_rgb_matrix['matrix_instance'].brightness == 70
class TestDisplayManagerOrientation:
"""The orientation setting composes onto pixel_mapper_config for panels
mounted upside down, without disturbing a custom pixel_mapper_config."""
def _config(self, **hardware_overrides):
config = {
'display': {
'hardware': {
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90,
},
'runtime': {'gpio_slowdown': 2},
},
'timezone': 'UTC',
'plugin_system': {'plugins_directory': 'plugins'},
}
config['display']['hardware'].update(hardware_overrides)
return config
def test_default_orientation_leaves_pixel_mapper_config_untouched(self, mock_rgb_matrix):
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
DisplayManager(self._config(), suppress_test_pattern=True)
options = mock_rgb_matrix['options_class'].return_value
assert options.pixel_mapper_config == ''
def test_orientation_180_appends_rotate_mapper(self, mock_rgb_matrix):
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
DisplayManager(self._config(orientation='180'), suppress_test_pattern=True)
options = mock_rgb_matrix['options_class'].return_value
assert options.pixel_mapper_config == 'Rotate:180'
def test_orientation_180_composes_with_existing_pixel_mapper_config(self, mock_rgb_matrix):
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
DisplayManager(self._config(orientation='180', pixel_mapper_config='U-mapper'),
suppress_test_pattern=True)
options = mock_rgb_matrix['options_class'].return_value
assert options.pixel_mapper_config == 'U-mapper;Rotate:180'
+259
View File
@@ -0,0 +1,259 @@
"""
Tests for src/dynamic_team_resolver.py (DynamicTeamResolver).
Covers dynamic team expansion (AP_TOP_5/10/25), order-preserving dedup,
unknown dynamic-name dropping, rankings parsing, the fixed genuinely
class-shared rankings cache (fetch and clear_cache write through
DynamicTeamResolver._rankings_cache / _cache_timestamp), TTL expiry,
network-failure resilience, and the resolve_dynamic_teams module function.
No real network: src.dynamic_team_resolver.requests.get is always patched.
"""
import types
from unittest.mock import MagicMock, patch
import pytest
import requests
import src.dynamic_team_resolver as dtr_module
from src.dynamic_team_resolver import DynamicTeamResolver, resolve_dynamic_teams
TOP_TEAMS = ['UGA', 'MICH', 'OSU', 'TEX', 'ALA', 'ORE', 'PSU', 'ND', 'FSU', 'OU']
def _rankings_payload(teams=None):
teams = TOP_TEAMS if teams is None else teams
return {
'rankings': [{
'name': 'AP Top 25',
'ranks': [
{'current': i + 1, 'team': {'abbreviation': abbr}}
for i, abbr in enumerate(teams)
],
}]
}
def _make_response(payload):
response = MagicMock()
response.json.return_value = payload
response.raise_for_status.return_value = None
return response
@pytest.fixture(autouse=True)
def reset_class_cache():
"""Reset the CLASS-level shared cache between tests."""
DynamicTeamResolver._rankings_cache = {}
DynamicTeamResolver._cache_timestamp = 0
yield
DynamicTeamResolver._rankings_cache = {}
DynamicTeamResolver._cache_timestamp = 0
@pytest.fixture
def mock_get():
with patch('src.dynamic_team_resolver.requests.get') as m:
m.return_value = _make_response(_rankings_payload())
yield m
@pytest.fixture
def resolver():
return DynamicTeamResolver()
# ---------------------------------------------------------------------------
# resolve_teams basics
# ---------------------------------------------------------------------------
class TestResolveTeamsBasics:
def test_empty_list_returns_empty_no_http(self, resolver, mock_get):
assert resolver.resolve_teams([]) == []
mock_get.assert_not_called()
def test_no_dynamic_names_passthrough_no_http(self, resolver, mock_get):
assert resolver.resolve_teams(['UGA', 'AUB', 'LSU']) == [
'UGA', 'AUB', 'LSU']
mock_get.assert_not_called()
def test_expansion_inserted_in_place_order_preserved(
self, resolver, mock_get):
result = resolver.resolve_teams(['UGA', 'AP_TOP_5', 'AUB'])
# UGA is also ranked #1, so dedup keeps its first occurrence; the
# top-5 expansion lands where AP_TOP_5 appeared, AUB stays after.
assert result == ['UGA', 'MICH', 'OSU', 'TEX', 'ALA', 'AUB']
def test_order_preserving_dedup(self, resolver, mock_get):
result = resolver.resolve_teams(['UGA', 'AP_TOP_5', 'UGA'])
assert result == ['UGA', 'MICH', 'OSU', 'TEX', 'ALA']
assert result.count('UGA') == 1
assert result[0] == 'UGA'
# ---------------------------------------------------------------------------
# AP_TOP_N slicing
# ---------------------------------------------------------------------------
class TestSlicing:
def test_top_n_counts_and_order(self, resolver, mock_get):
teams_25 = [f'T{i:02d}' for i in range(1, 26)]
mock_get.return_value = _make_response(_rankings_payload(teams_25))
top5 = resolver.resolve_teams(['AP_TOP_5'])
top10 = resolver.resolve_teams(['AP_TOP_10'])
top25 = resolver.resolve_teams(['AP_TOP_25'])
assert top5 == teams_25[:5]
assert top10 == teams_25[:10]
assert top25 == teams_25
# ---------------------------------------------------------------------------
# Unknown dynamic-looking names
# ---------------------------------------------------------------------------
class TestUnknownDynamicNames:
def test_unknown_dynamic_looking_names_dropped(self, resolver, mock_get):
result = resolver.resolve_teams(
['AP_TOP_100', 'TOP_10', 'RANKED_ALL', 'PLAYOFF_TEAMS'])
assert result == []
mock_get.assert_not_called()
def test_top_substring_hazard(self, resolver, mock_get):
# Hazard pin: _is_potential_dynamic_team matches the substring
# 'TOP_' anywhere in the (upper-cased) name, so a team literally
# named 'TOP_GUN' is dropped as an unknown dynamic team too.
assert resolver.resolve_teams(['TOP_GUN']) == []
# ---------------------------------------------------------------------------
# Rankings parsing
# ---------------------------------------------------------------------------
class TestRankingsParsing:
def test_drops_zero_rank_and_empty_abbreviation_sorts_ascending(
self, resolver, mock_get):
payload = {
'rankings': [{
'name': 'AP Top 25',
'ranks': [
{'current': 3, 'team': {'abbreviation': 'C3'}},
{'current': 1, 'team': {'abbreviation': 'A1'}},
{'current': 0, 'team': {'abbreviation': 'ZERO'}},
{'current': 4, 'team': {'abbreviation': ''}},
{'current': 2, 'team': {'abbreviation': 'B2'}},
],
}]
}
mock_get.return_value = _make_response(payload)
rankings = resolver._fetch_ncaa_fb_rankings()
assert list(rankings.keys()) == ['A1', 'B2', 'C3']
assert list(rankings.values()) == [1, 2, 3]
def test_empty_rankings_returns_empty_and_caches_nothing(
self, resolver, mock_get):
mock_get.return_value = _make_response({'rankings': []})
assert resolver._fetch_ncaa_fb_rankings() == {}
# Nothing was cached, so the next call hits HTTP again.
assert resolver._fetch_ncaa_fb_rankings() == {}
assert mock_get.call_count == 2
# ---------------------------------------------------------------------------
# Shared class cache (fixed behavior)
# ---------------------------------------------------------------------------
class TestSharedCache:
def test_cache_shared_across_instances(self, mock_get):
resolver1 = DynamicTeamResolver()
resolver1.resolve_teams(['AP_TOP_5'])
assert mock_get.call_count == 1
resolver2 = DynamicTeamResolver()
result = resolver2.resolve_teams(['AP_TOP_5'])
# Post-fix: the class-level cache serves the second instance with
# ZERO additional HTTP calls.
assert result == TOP_TEAMS[:5]
assert mock_get.call_count == 1
def test_ttl_expiry_refetches(self, resolver, mock_get, monkeypatch):
resolver.resolve_teams(['AP_TOP_5'])
assert mock_get.call_count == 1
stamp = DynamicTeamResolver._cache_timestamp
monkeypatch.setattr(
dtr_module, 'time', types.SimpleNamespace(time=lambda: stamp + 3601))
resolver.resolve_teams(['AP_TOP_5'])
assert mock_get.call_count == 2
def test_clear_cache_through_one_instance_affects_all(self, mock_get):
resolver1 = DynamicTeamResolver()
resolver1.resolve_teams(['AP_TOP_5'])
assert mock_get.call_count == 1
resolver2 = DynamicTeamResolver()
resolver2.clear_cache()
# Post-fix: clear_cache writes through the class, so resolver1
# must refetch even though resolver2 did the clearing.
resolver1.resolve_teams(['AP_TOP_5'])
assert mock_get.call_count == 2
def test_module_function_benefits_from_class_cache(self, mock_get):
# resolve_dynamic_teams constructs a fresh resolver per call, but
# the class-shared cache means only the first call hits HTTP.
first = resolve_dynamic_teams(['AP_TOP_5'])
second = resolve_dynamic_teams(['AP_TOP_5'])
assert first == second == TOP_TEAMS[:5]
assert mock_get.call_count == 1
# ---------------------------------------------------------------------------
# Failure handling
# ---------------------------------------------------------------------------
class TestFailureHandling:
def test_network_failure_drops_dynamic_keeps_static_caches_nothing(
self, resolver, mock_get):
mock_get.side_effect = [
requests.exceptions.RequestException('boom'),
_make_response(_rankings_payload()),
]
result = resolver.resolve_teams(['UGA', 'AP_TOP_5'])
# Dynamic name silently dropped, static name kept, nothing raises.
assert result == ['UGA']
# Nothing was cached on failure: a subsequent call refetches and
# succeeds.
result = resolver.resolve_teams(['UGA', 'AP_TOP_5'])
assert result == ['UGA', 'MICH', 'OSU', 'TEX', 'ALA']
assert mock_get.call_count == 2
# ---------------------------------------------------------------------------
# sport argument
# ---------------------------------------------------------------------------
class TestSportArgument:
def test_sport_arg_ignored_for_expansion(self, resolver, mock_get):
# Pin: the sport argument is effectively ignored — each pattern
# carries its own sport ('ncaa_fb'), so passing sport='nfl' still
# expands from the college-football rankings.
result = resolver.resolve_teams(['AP_TOP_5'], sport='nfl')
assert result == TOP_TEAMS[:5]
assert mock_get.call_count == 1
+103
View File
@@ -0,0 +1,103 @@
"""Tests for the harness empty-frame check (src/plugin_system/testing/harness.py).
The display controller skips a mode whose display() returns False and treats
anything else -- including None -- as "content was shown". A mode that draws
nothing without returning False is therefore never skipped, and since a mode
switch clears the panel first, it sits on a blank screen for its whole display
duration.
Two sports plugins shipped exactly that: their display() returned None on every
path, so an out-of-season league held a blank panel instead of being rotated
past. The harness rendered those modes and passed them, because it discarded
the return value entirely.
"""
from PIL import Image
from src.plugin_system.testing.harness import RenderResult, check_empty_claimed
def _blank(w=64, h=32):
return Image.new("RGB", (w, h), (0, 0, 0))
def _drawn(w=64, h=32):
img = _blank(w, h)
img.paste(Image.new("RGB", (10, 10), (255, 255, 255)), (5, 5))
return img
def _result(image, returned=None, **kw):
return RenderResult("p", 64, 32, "mode", image=image,
display_returned=returned, **kw)
class TestCheckEmptyClaimed:
def test_blank_frame_returning_none_is_flagged(self):
# The shape that shipped: nothing drawn, nothing reported.
r = _result(_blank(), returned=None)
check_empty_claimed([r])
assert r.empty_claimed is True
def test_blank_frame_returning_true_is_flagged(self):
# Just as broken, and more explicit about it.
r = _result(_blank(), returned=True)
check_empty_claimed([r])
assert r.empty_claimed is True
def test_blank_frame_returning_false_is_fine(self):
# The plugin correctly said "no content"; the controller will skip it.
r = _result(_blank(), returned=False)
check_empty_claimed([r])
assert r.empty_claimed is None
def test_a_drawn_frame_is_fine_whatever_it_returns(self):
for returned in (None, True, False):
r = _result(_drawn(), returned=returned)
check_empty_claimed([r])
assert r.empty_claimed is None, returned
def test_near_black_still_counts_as_drawn(self):
# Guard the threshold: content dim enough to look black to the eye is
# still content, and flagging it would train people to ignore this.
img = _blank()
img.paste(Image.new("RGB", (4, 4), (60, 60, 60)), (2, 2))
r = _result(img, returned=None)
check_empty_claimed([r])
assert r.empty_claimed is None
class TestWarnVersusStrict:
def test_warn_only_by_default(self):
# A scroll mode's first frame is legitimately its blank scroll-in
# buffer, so this must not fail a run unless opted in.
r = _result(_blank(), returned=None)
check_empty_claimed([r])
assert r.empty_ok is None
assert r.ok is True
def test_strict_fails_the_result(self):
r = _result(_blank(), returned=None)
check_empty_claimed([r], strict=True)
assert r.empty_ok is False
assert r.ok is False
def test_strict_still_allows_an_honest_false(self):
r = _result(_blank(), returned=False)
check_empty_claimed([r], strict=True)
assert r.empty_ok is None
assert r.ok is True
class TestSkippedResults:
def test_a_crashed_render_is_left_alone(self):
# error already fails the result; adding a second reason just muddies
# the report.
r = _result(None, returned=None, error="boom")
check_empty_claimed([r], strict=True)
assert r.empty_claimed is None
def test_a_result_with_no_image_is_left_alone(self):
r = _result(None, returned=None)
check_empty_claimed([r], strict=True)
assert r.empty_claimed is None
+224
View File
@@ -0,0 +1,224 @@
"""Tests that startup does not wait indefinitely for plugins to fetch data.
DisplayController.__init__ calls _update_modules() once, to populate plugin
data before the first frame. It walks every loaded plugin in turn, and each
update blocks the calling thread for up to the executor's 30s timeout, so the
uncapped total is the sum of every slow plugin on the system.
Profiled on a live rig with py-spy, the main thread sat 9.34s in
display_controller._update_modules
-> plugin_executor.execute_update
-> execute_with_timeout -> threading.join
and the controller's own log put the full pass at 82 seconds on the worst
boot measured (55 and 26 on the two before). The panel shows nothing for all
of it.
Nothing is lost by stopping early: a plugin that has never updated is
immediately due, so run_scheduled_updates() collects it seconds later with the
display already running.
"""
import os
import time
from unittest.mock import Mock
import pytest
# display_controller imports display_manager, which binds the hardware
# rgbmatrix module unless EMULATOR=true is set before import (same convention
# as test_display_controller_vegas_tick.py).
os.environ.setdefault("EMULATOR", "true")
from src.display_controller import ( # noqa: E402
DisplayController, _INITIAL_UPDATE_BUDGET_SECONDS,
_MIN_INITIAL_UPDATE_TIMEOUT_SECONDS)
class FakeExecutor:
"""Records which plugins were updated, and can make some of them slow."""
def __init__(self, cost=0.0, slow=()):
self.updated = []
self.cost = cost
self.slow = set(slow)
def execute_update(self, plugin, plugin_id, timeout=None):
self.updated.append(plugin_id)
if plugin_id in self.slow:
time.sleep(self.cost)
return True
@pytest.fixture
def tiny_floor(monkeypatch):
"""Shrink the "worth starting" floor so timing tests stay quick."""
import src.display_controller as mod
monkeypatch.setattr(mod, "_MIN_INITIAL_UPDATE_TIMEOUT_SECONDS", 0.01)
def _controller(plugin_ids, executor):
c = DisplayController.__new__(DisplayController)
c.plugin_manager = Mock()
# Both attributes, because _update_modules reads
# `loaded_plugins or plugins` and an empty dict is falsy.
c.plugin_manager.loaded_plugins = {pid: Mock() for pid in plugin_ids}
c.plugin_manager.plugins = dict(c.plugin_manager.loaded_plugins)
c.plugin_manager.plugin_executor = executor
c.plugin_manager.plugin_last_update = {}
c.plugin_manager.health_tracker = None
return c
class TestTheBudgetIsRespected:
def test_without_a_deadline_every_plugin_is_updated(self):
ex = FakeExecutor()
_controller(['a', 'b', 'c'], ex)._update_modules()
assert ex.updated == ['a', 'b', 'c']
def test_a_passed_deadline_stops_the_pass(self):
ex = FakeExecutor()
_controller(['a', 'b', 'c'], ex)._update_modules(deadline=time.time() - 1)
assert ex.updated == [], "updated %r after the deadline" % ex.updated
def test_slow_plugins_do_not_drag_in_the_rest(self, tiny_floor):
# One plugin burns the whole budget; the remainder must be left alone
# rather than each adding its own wait.
ex = FakeExecutor(cost=0.3, slow={'slow'})
c = _controller(['slow'] + ['p%d' % i for i in range(20)], ex)
started = time.time()
c._update_modules(deadline=started + 0.2)
elapsed = time.time() - started
assert ex.updated == ['slow'], "updated %r" % ex.updated
# Bounded by the one in-flight update, not by twenty more.
assert elapsed < 1.0, "%.2fs" % elapsed
def test_a_generous_deadline_still_gets_everything(self):
ex = FakeExecutor()
c = _controller(['a', 'b', 'c'], ex)
c._update_modules(deadline=time.time() + 30)
assert ex.updated == ['a', 'b', 'c']
def test_the_deadline_is_checked_before_each_plugin(self, tiny_floor):
# Not just once up front: the budget can be spent partway through.
ex = FakeExecutor(cost=0.15, slow={'a', 'b', 'c', 'd'})
c = _controller(['a', 'b', 'c', 'd'], ex)
c._update_modules(deadline=time.time() + 0.2)
assert 0 < len(ex.updated) < 4, "updated %r" % ex.updated
class TestThePassIsBoundedInPractice:
def test_the_last_plugin_cannot_overrun_the_budget(self):
# Checking the deadline before each plugin is not enough on its own:
# one that starts with a moment left could still block for the
# executor's full timeout. On the rig that turned a 20s budget into a
# 31.8s pass, so the remaining budget is passed down as the timeout.
seen = []
class Executor:
def execute_update(self, plugin, plugin_id, timeout=None):
seen.append(timeout)
return True
c = _controller(['a', 'b', 'c'], Executor())
deadline = time.time() + 5
c._update_modules(deadline=deadline)
assert seen and all(t is not None for t in seen), seen
assert all(t <= 5.01 for t in seen), seen
# The exact remainder, never clamped up: clamping would let the pass
# run past its deadline. Anything below the floor is deferred instead,
# so what does start always has a usable slot.
assert all(t >= _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS for t in seen), seen
def test_without_a_deadline_the_executor_default_is_left_alone(self):
seen = []
class Executor:
def execute_update(self, plugin, plugin_id, timeout=None):
seen.append(timeout)
return True
_controller(['a'], Executor())._update_modules()
assert seen == [None], seen
class TestTheBudgetItself:
def test_it_is_short_enough_to_be_worth_having(self):
# The measured uncapped worst case was 82s; a budget near that would
# not bound anything.
assert _INITIAL_UPDATE_BUDGET_SECONDS <= 30
def test_it_is_long_enough_for_a_quick_plugin_or_two(self):
assert _INITIAL_UPDATE_BUDGET_SECONDS >= 5
class TestNothingIsSilentlyDropped:
def test_deferred_plugins_are_named_in_the_log(self, caplog):
ex = FakeExecutor()
c = _controller(['a', 'b'], ex)
with caplog.at_level('INFO'):
c._update_modules(deadline=time.time() - 1)
text = "\n".join(r.getMessage() for r in caplog.records)
assert 'a' in text and 'b' in text, text
assert 'budget' in text.lower(), text
def test_nothing_is_logged_when_all_of_them_ran(self, caplog):
ex = FakeExecutor()
c = _controller(['a'], ex)
with caplog.at_level('INFO'):
c._update_modules(deadline=time.time() + 30)
assert not any('budget' in r.getMessage().lower() for r in caplog.records)
class TestItDoesNotBreakTheOrdinaryPaths:
def test_no_plugin_manager_is_harmless(self):
c = DisplayController.__new__(DisplayController)
c.plugin_manager = None
c._update_modules(deadline=time.time() - 1) # must not raise
def test_an_empty_plugin_set_is_harmless(self):
ex = FakeExecutor()
_controller([], ex)._update_modules(deadline=time.time() + 5)
assert ex.updated == []
class TestTooLittleBudgetDefersRatherThanClamps:
def test_a_plugin_starting_below_the_floor_is_deferred(self):
ex = FakeExecutor()
c = _controller(['a'], ex)
# Just under the floor: previously this was clamped up to the floor and
# run anyway, which pushed the pass past its deadline.
c._update_modules(
deadline=time.time() + _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS - 0.05)
assert ex.updated == [], "started a plugin it could not give a slot to"
def test_a_plugin_starting_above_the_floor_still_runs(self):
ex = FakeExecutor()
c = _controller(['a'], ex)
c._update_modules(
deadline=time.time() + _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS + 1)
assert ex.updated == ['a']
def test_the_timeout_is_the_remainder_not_the_floor(self):
seen = []
class Executor:
def execute_update(self, plugin, plugin_id, timeout=None):
seen.append(timeout)
return True
c = _controller(['a'], Executor())
c._update_modules(deadline=time.time() + 9)
assert seen and 8.5 <= seen[0] <= 9.01, seen
def test_the_pass_cannot_outlast_its_deadline(self, tiny_floor):
# Every plugin sleeps well past the budget; the deferral keeps the
# whole pass inside it rather than overrunning by a floor's worth.
ex = FakeExecutor(cost=0.4, slow={'a', 'b', 'c', 'd', 'e'})
c = _controller(['a', 'b', 'c', 'd', 'e'], ex)
started = time.time()
c._update_modules(deadline=started + 0.5)
assert time.time() - started < 1.2, "%.2fs" % (time.time() - started)
+190
View File
@@ -0,0 +1,190 @@
"""Tests the startup screen that shows while plugins fetch their first data.
That screen is on the panel for the whole initial-update window, and on a
headless Pi it is the only place the device's address appears without going
looking for it -- so it now carries the address as well as "Initializing".
Two things have to hold. It must fit every supported panel: the old fixed
8px PressStart2P drew "Initializing" 96px wide at x=10, which ran off the
side of a 64px panel before an address was ever added. And the lookup must be
cheap, because this runs on the startup path that the rest of this change
exists to shorten.
"""
import os
import time
from PIL import Image, ImageDraw, ImageFont
import pytest
os.environ.setdefault("EMULATOR", "true")
from src.display_manager import DisplayManager # noqa: E402
SIZES = [(64, 32), (128, 32), (128, 64), (256, 32), (512, 64)]
class FakeMatrix:
def __init__(self, width, height):
self.width, self.height = width, height
def _manager(width, height):
dm = DisplayManager.__new__(DisplayManager)
dm.image = Image.new('RGB', (width, height))
dm.draw = ImageDraw.Draw(dm.image)
dm.matrix = FakeMatrix(width, height)
dm.font = ImageFont.truetype('assets/fonts/PressStart2P-Regular.ttf', 8)
return dm
def _layout(dm, lines):
"""The geometry _draw_startup_banner uses."""
font = dm._fitting_font(lines, dm.matrix.width - 2)
line_height = dm.draw.textbbox((0, 0), "Ag", font=font)[3] + 1
top = max(1, (dm.matrix.height - line_height * len(lines)) // 2)
widths = [dm.draw.textlength(t, font=font) for t in lines]
return font, widths, top, top + line_height * len(lines)
def _render_over_pattern(width, height, lines):
"""Draw the test pattern, then the banner over it, as startup does."""
dm = _manager(width, height)
dm.draw.rectangle([0, 0, width - 1, height - 1], outline=(255, 0, 0))
dm.draw.line([0, 0, width - 1, height - 1], fill=(0, 255, 0))
dm._draw_startup_banner(lines, width, height)
return dm
class TestTheAddressLookup:
def test_it_never_reports_loopback(self):
# A loopback address on the panel would be actively misleading -- it is
# not something anyone can browse to.
ip = DisplayManager._local_ip()
assert ip is None or not ip.startswith("127."), ip
def test_it_looks_like_an_address_when_there_is_one(self):
ip = DisplayManager._local_ip()
if ip is None:
pytest.skip("host has no routable address")
parts = ip.split(".")
assert len(parts) == 4 and all(p.isdigit() for p in parts), ip
def test_it_is_cheap_enough_for_the_startup_path(self):
DisplayManager._local_ip() # warm anything cacheable
started = time.perf_counter()
for _ in range(20):
DisplayManager._local_ip()
per_call = (time.perf_counter() - started) / 20
# `hostname -I` with its 2s timeout, which the web launcher uses, would
# be thousands of times this.
assert per_call < 0.05, "%.1f ms per call" % (per_call * 1000)
def test_it_returns_none_rather_than_raising(self, monkeypatch):
import src.display_manager as mod
def no_network(*a, **k):
raise OSError("network is unreachable")
monkeypatch.setattr(mod.socket, "socket", no_network)
assert DisplayManager._local_ip() is None
class TestItFitsEveryPanel:
@pytest.mark.parametrize("width,height", SIZES)
def test_both_lines_fit_with_an_address(self, width, height):
dm = _manager(width, height)
_font, widths, top, bottom = _layout(dm, ["Initializing", "255.255.255.255"])
assert all(w <= width - 2 for w in widths), (width, widths)
assert bottom <= height and top >= 0, (top, bottom, height)
@pytest.mark.parametrize("width,height", SIZES)
def test_it_still_fits_with_no_address(self, width, height):
dm = _manager(width, height)
_font, widths, _top, bottom = _layout(dm, ["Initializing"])
assert all(w <= width - 2 for w in widths), (width, widths)
assert bottom <= height, (bottom, height)
def test_the_smallest_panel_drops_to_a_narrower_font(self):
# The regression this guards: PressStart2P at 8px is 96px wide for
# "Initializing", which does not fit 64px however it is positioned.
dm = _manager(64, 32)
font, widths, _t, _b = _layout(dm, ["Initializing", "10.0.20.104"])
assert font is not dm.font, "kept a font that cannot fit"
assert max(widths) <= 62, widths
def test_a_roomy_panel_keeps_the_larger_font(self):
dm = _manager(256, 32)
font, _w, _t, _b = _layout(dm, ["Initializing", "10.0.20.104"])
assert font is dm.font, "needlessly shrank on a panel with room"
class TestPlacement:
@pytest.mark.parametrize("width,height", SIZES)
def test_the_lines_are_centred(self, width, height):
dm = _manager(width, height)
lines = ["Initializing", "10.0.20.104"]
_font, widths, _t, _b = _layout(dm, lines)
for w in widths:
left = max(0, (width - w) // 2)
assert abs((left + (left + w)) - width) <= 2, (left, w, width)
def test_the_address_sits_under_the_word(self):
dm = _manager(128, 64)
font, _w, top, bottom = _layout(dm, ["Initializing", "10.0.20.104"])
line_height = dm.draw.textbbox((0, 0), "Ag", font=font)[3] + 1
assert bottom - top == line_height * 2
class TestItIsActuallyReadable:
"""The point of the address is that someone can read it off the wall."""
@pytest.mark.parametrize("width,height", SIZES)
def test_the_diagonal_does_not_cross_the_text(self, width, height):
lines = ["Initializing", "10.0.20.104"]
dm = _render_over_pattern(width, height, lines)
_font, widths, top, bottom = _layout(dm, lines)
# textlength returns a float, so these must be floored before they
# can index pixels.
block_width = int(max(widths))
left = int(max(0, (width - block_width) // 2))
px = dm.image.load()
green = 0
for y in range(int(top), min(int(bottom), height)):
for x in range(left, min(left + block_width, width)):
r, g, b = px[x, y]
if g > 128 and r < 128 and b < 128:
green += 1
assert green == 0, "%d green pixels behind the text at %dx%d" % (
green, width, height)
@pytest.mark.parametrize("width,height", SIZES)
def test_the_text_stays_pure_blue(self, width, height):
# Not a style choice. The pattern lights one channel per element --
# red border, green diagonal, blue text -- so a glance says whether
# led_rgb_sequence is right: wire it BGR and the border comes up blue
# and this text red. White text would light all three and destroy the
# only blue reference on the screen.
dm = _render_over_pattern(width, height, ["Initializing", "10.0.20.104"])
px = dm.image.load()
blue = sum(1 for y in range(height) for x in range(width)
if px[x, y] == (0, 0, 255))
assert blue > 20, "only %d blue pixels at %dx%d" % (blue, width, height)
white = sum(1 for y in range(height) for x in range(width)
if px[x, y] == (255, 255, 255))
assert white == 0, "%d white pixels would muddy the channel check" % white
def test_each_element_lights_one_channel(self):
# The whole point of the pattern: three pure primaries on screen.
dm = _render_over_pattern(128, 64, ["Initializing", "10.0.20.104"])
seen = set(dm.image.getdata())
assert (255, 0, 0) in seen, "no pure red border"
assert (0, 255, 0) in seen, "no pure green diagonal"
assert (0, 0, 255) in seen, "no pure blue text"
def test_nothing_is_drawn_for_no_lines(self):
dm = _manager(128, 64)
before = dm.image.tobytes()
dm._draw_startup_banner([], 128, 64)
assert dm.image.tobytes() == before
+274
View File
@@ -0,0 +1,274 @@
"""
Tests for src/logging_config.py the formatters, adapter, and setup used
by every logger in the system (BasePlugin uses get_logger, not stdlib
logging.getLogger).
Includes regression guards for two fixed bugs: ContextualFormatter used to
mutate record.msg in place (double-prefixing with two handlers), and
log_error hardcoded exc_info=True so passing it explicitly raised
TypeError.
"""
import json
import logging
import sys
import pytest
from src.logging_config import (
ContextualFormatter,
PluginLoggerAdapter,
StructuredFormatter,
get_logger,
log_debug,
log_error,
log_info,
log_warning,
log_with_context,
setup_logging,
)
def make_record(msg="hello", level=logging.INFO, **extra):
record = logging.LogRecord(
name="test.logger", level=level, pathname=__file__, lineno=42,
msg=msg, args=(), exc_info=None)
for key, value in extra.items():
setattr(record, key, value)
return record
class TestStructuredFormatter:
def test_emits_valid_json_with_base_keys(self):
out = json.loads(StructuredFormatter().format(make_record()))
assert set(out) == {
"timestamp", "level", "logger", "message",
"module", "function", "line",
}
assert out["level"] == "INFO"
assert out["message"] == "hello"
assert out["logger"] == "test.logger"
def test_optional_keys_only_when_present(self):
record = make_record(context={"k": "v"}, plugin_id="clock",
operation_id="op-1")
out = json.loads(StructuredFormatter().format(record))
assert out["context"] == {"k": "v"}
assert out["plugin_id"] == "clock"
assert out["operation_id"] == "op-1"
def test_exception_key_when_exc_info_present(self):
try:
raise ValueError("kaboom")
except ValueError:
record = logging.LogRecord(
name="t", level=logging.ERROR, pathname=__file__, lineno=1,
msg="failed", args=(), exc_info=sys.exc_info())
out = json.loads(StructuredFormatter().format(record))
assert "kaboom" in out["exception"]
def test_percent_args_formatted_into_message(self):
record = logging.LogRecord(
name="t", level=logging.INFO, pathname=__file__, lineno=1,
msg="count=%d", args=(7,), exc_info=None)
out = json.loads(StructuredFormatter().format(record))
assert out["message"] == "count=7"
class TestContextualFormatter:
def test_context_prefix_prepended(self):
record = make_record(plugin_id="clock", operation_id="op-1",
context={"k": "v"})
out = ContextualFormatter().format(record)
assert "[Plugin: clock] [Op: op-1] [k: v] hello" in out
def test_include_context_false_leaves_message_bare(self):
record = make_record(plugin_id="clock")
out = ContextualFormatter(include_context=False).format(record)
assert "[Plugin:" not in out
assert "hello" in out
def test_location_toggle(self):
record = make_record()
with_loc = ContextualFormatter(include_location=True).format(record)
without = ContextualFormatter(include_location=False).format(record)
assert f":{record.lineno}" in with_loc
assert f":{record.lineno}" not in without
def test_record_not_mutated_no_double_prefix(self):
# Regression: a record is formatted once PER HANDLER. The formatter
# must not mutate record.msg, or the second handler's format call
# prepends the prefix again.
record = make_record(plugin_id="clock")
formatter = ContextualFormatter()
first = formatter.format(record)
second = formatter.format(record)
assert record.msg == "hello" # untouched
assert first.count("[Plugin: clock]") == 1
assert second.count("[Plugin: clock]") == 1
def test_percent_args_still_format_after_copy(self):
record = logging.LogRecord(
name="t", level=logging.INFO, pathname=__file__, lineno=1,
msg="count=%d", args=(7,), exc_info=None)
record.plugin_id = "clock"
out = ContextualFormatter().format(record)
assert "[Plugin: clock] count=7" in out
def test_exception_renders_through_two_handlers(self):
try:
raise ValueError("kaboom")
except ValueError:
record = logging.LogRecord(
name="t", level=logging.ERROR, pathname=__file__, lineno=1,
msg="failed", args=(), exc_info=sys.exc_info())
record.plugin_id = "clock"
formatter = ContextualFormatter()
assert "kaboom" in formatter.format(record)
assert "kaboom" in formatter.format(record) # second handler's pass
class TestPluginLoggerAdapter:
def _capture(self, adapter):
records = []
handler = logging.Handler()
handler.emit = records.append
adapter.logger.addHandler(handler)
adapter.logger.setLevel(logging.DEBUG)
return records
def test_stamps_plugin_id_on_every_record(self):
adapter = get_logger("test.adapter1", plugin_id="clock")
records = self._capture(adapter)
adapter.info("x")
assert records[0].plugin_id == "clock"
def test_explicit_extra_plugin_id_wins(self):
adapter = get_logger("test.adapter2", plugin_id="clock")
records = self._capture(adapter)
adapter.info("x", extra={"plugin_id": "other"})
assert records[0].plugin_id == "other"
def test_unrelated_extra_keys_preserved(self):
adapter = get_logger("test.adapter3", plugin_id="clock")
records = self._capture(adapter)
adapter.info("x", extra={"custom": 1})
assert records[0].plugin_id == "clock"
assert records[0].custom == 1
class TestGetLogger:
def test_plain_logger_without_plugin_id(self):
logger = get_logger("test.plain")
assert isinstance(logger, logging.Logger)
assert logger.name == "test.plain"
def test_adapter_with_plugin_id(self):
adapter = get_logger("test.wrapped", plugin_id="clock")
assert isinstance(adapter, PluginLoggerAdapter)
assert adapter.logger.name == "test.wrapped"
class TestSetupLogging:
# conftest's autouse reset_logging restores root handlers after each test.
def test_installs_single_stdout_handler(self):
setup_logging()
root = logging.getLogger()
assert len(root.handlers) == 1
assert isinstance(root.handlers[0], logging.StreamHandler)
def test_repeat_calls_do_not_accumulate_handlers(self):
setup_logging()
setup_logging()
assert len(logging.getLogger().handlers) == 1
def test_json_format_selects_structured_formatter(self):
setup_logging(format_type="json")
assert isinstance(
logging.getLogger().handlers[0].formatter, StructuredFormatter)
def test_readable_format_selects_contextual_formatter(self):
setup_logging(format_type="readable")
assert isinstance(
logging.getLogger().handlers[0].formatter, ContextualFormatter)
def test_log_file_adds_file_handler(self, tmp_path):
log_file = tmp_path / "test.log"
setup_logging(log_file=str(log_file))
root = logging.getLogger()
file_handlers = [h for h in root.handlers
if isinstance(h, logging.FileHandler)]
assert len(file_handlers) == 1
for h in file_handlers:
h.close()
def test_unwritable_log_file_warns_and_keeps_console(self, tmp_path, capsys):
bad_path = tmp_path / "no-such-dir" / "test.log"
setup_logging(log_file=str(bad_path)) # must not raise
assert len(logging.getLogger().handlers) == 1 # console only
assert "Could not set up file logging" in capsys.readouterr().err
def test_debug_env_true_enables_debug(self, monkeypatch):
monkeypatch.setenv("LEDMATRIX_DEBUG", "TRUE")
setup_logging()
assert logging.getLogger().level == logging.DEBUG
def test_debug_env_other_values_stay_info(self, monkeypatch):
# Pinned: only the literal (case-insensitive) "true" enables debug;
# "1" does not.
monkeypatch.setenv("LEDMATRIX_DEBUG", "1")
setup_logging()
assert logging.getLogger().level == logging.INFO
def test_explicit_level_wins_over_env(self, monkeypatch):
monkeypatch.setenv("LEDMATRIX_DEBUG", "true")
setup_logging(level=logging.WARNING)
assert logging.getLogger().level == logging.WARNING
class TestLogWithContext:
def _capture(self, name):
logger = logging.getLogger(name)
records = []
handler = logging.Handler()
handler.emit = records.append
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
return logger, records
def test_context_attrs_stamped(self):
logger, records = self._capture("test.lwc1")
log_with_context(logger, logging.INFO, "msg",
context={"k": "v"}, plugin_id="clock",
operation_id="op-1")
record = records[0]
assert record.context == {"k": "v"}
assert record.plugin_id == "clock"
assert record.operation_id == "op-1"
def test_wrappers_use_their_levels(self):
logger, records = self._capture("test.lwc2")
log_debug(logger, "d")
log_info(logger, "i")
log_warning(logger, "w")
assert [r.levelno for r in records] == [
logging.DEBUG, logging.INFO, logging.WARNING]
def test_log_error_defaults_exc_info_true(self):
logger, records = self._capture("test.lwc3")
try:
raise ValueError("kaboom")
except ValueError:
log_error(logger, "failed")
assert records[0].levelno == logging.ERROR
assert records[0].exc_info is not None
def test_log_error_accepts_explicit_exc_info(self):
# Regression: the old hardcoded exc_info=True raised
# "got multiple values for keyword argument 'exc_info'".
logger, records = self._capture("test.lwc4")
log_error(logger, "failed", exc_info=False)
# Falsy exc_info is stored verbatim on the record; the contract is
# simply "no traceback attached".
assert not records[0].exc_info
+423
View File
@@ -0,0 +1,423 @@
"""
Tests for src/common/logo_helper.py logo loading, LRU caching, resizing,
and download-with-fallback. Previously untested: nothing in test/ referenced
this module at all.
Real PIL images under tmp_path are used rather than mocked ones, since
load_logo() does real Path.exists() and Image.open() calls; only the HTTP
session and the permission helpers are patched.
Regression coverage for two fixed bugs:
- _download_logo wrote response.content to disk with no size cap and no
check that the bytes decoded as an image, so a hostile or broken URL
could leave arbitrary/oversized content cached in the assets directory.
- get_cache_stats() divided by self.cache_size unguarded, raising
ZeroDivisionError for a helper constructed with cache_size=0.
"""
import logging
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import requests
from PIL import Image, UnidentifiedImageError
from src.common.logo_helper import MAX_LOGO_BYTES, LogoHelper
@pytest.fixture(autouse=True)
def _no_real_chmod(monkeypatch):
# Keep the permission helpers out of the way: their own env detection
# is not what these tests are about.
monkeypatch.setattr("src.common.logo_helper.ensure_directory_permissions", MagicMock())
monkeypatch.setattr("src.common.logo_helper.ensure_file_permissions", MagicMock())
@pytest.fixture
def helper():
return LogoHelper(display_width=64, display_height=32,
logger=logging.getLogger("test.logo_helper"))
def write_logo(path: Path, size=(20, 20), color=(255, 0, 0), fmt="PNG") -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", size, color).save(path, format=fmt)
return path
def fake_response(content: bytes, chunk_size: int = 64 * 1024):
"""Stand-in for a streamed requests.Response.
_download_logo opens `with session.get(..., stream=True)` and reads
through iter_content(), so the fake has to be a context manager that
yields the body in pieces rather than exposing it as .content.
Chunking is the fake's own, not the caller's, so a test can dribble a
body out in small pieces.
"""
response = MagicMock()
response.__enter__.return_value = response
response.__exit__.return_value = False
response.raise_for_status = MagicMock()
def _iter_content(*_args, **_kwargs):
for i in range(0, len(content), chunk_size):
yield content[i:i + chunk_size]
response.iter_content = _iter_content
return response
def endless_response(chunk: bytes = b"\x00" * 65536):
"""A server that declares no length and never stops sending.
This is the case response.content could not survive: it buffers to
completion, so the size check never got a chance to run.
"""
response = MagicMock()
response.__enter__.return_value = response
response.__exit__.return_value = False
response.raise_for_status = MagicMock()
def _iter_content(*_args, **_kwargs):
while True:
yield chunk
response.iter_content = _iter_content
return response
def png_bytes(size=(20, 20), color=(0, 128, 0)) -> bytes:
import io
buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, format="PNG")
return buf.getvalue()
class TestLoadLogo:
def test_loads_and_converts_to_rgba(self, helper, tmp_path):
path = write_logo(tmp_path / "PHI.png")
logo = helper.load_logo("PHI", path)
assert logo is not None
assert logo.mode == "RGBA"
def test_missing_file_returns_none(self, helper, tmp_path, caplog):
with caplog.at_level(logging.WARNING):
assert helper.load_logo("NOPE", tmp_path / "missing.png") is None
assert "Logo not found" in caplog.text
def test_second_load_is_served_from_cache(self, helper, tmp_path):
path = write_logo(tmp_path / "PHI.png")
first = helper.load_logo("PHI", path)
path.unlink() # cache hit must not touch the filesystem
assert helper.load_logo("PHI", path) is first
def test_cache_key_includes_requested_size(self, helper, tmp_path):
# A panel-size change must not hand back a logo sized for the old
# dimensions, so the two sizes get separate cache entries.
path = write_logo(tmp_path / "PHI.png", size=(100, 100))
small = helper.load_logo("PHI", path, max_width=10, max_height=10)
large = helper.load_logo("PHI", path, max_width=50, max_height=50)
assert small is not large
assert small.size != large.size
assert len(helper._logo_cache) == 2
def test_default_size_is_one_and_a_half_display(self, helper, tmp_path):
path = write_logo(tmp_path / "PHI.png", size=(500, 500))
logo = helper.load_logo("PHI", path)
assert logo.width <= int(64 * 1.5)
assert logo.height <= int(32 * 1.5)
def test_smaller_image_is_not_upscaled(self, helper, tmp_path):
path = write_logo(tmp_path / "PHI.png", size=(8, 8))
assert helper.load_logo("PHI", path, max_width=64, max_height=64).size == (8, 8)
def test_larger_image_is_downscaled_preserving_aspect(self, helper, tmp_path):
path = write_logo(tmp_path / "PHI.png", size=(200, 100))
logo = helper.load_logo("PHI", path, max_width=50, max_height=50)
assert logo.width <= 50 and logo.height <= 50
assert logo.width == 50 and logo.height == 25 # 2:1 preserved
def test_string_path_accepted(self, helper, tmp_path):
path = write_logo(tmp_path / "PHI.png")
assert helper.load_logo("PHI", str(path)) is not None
def test_corrupt_file_returns_none(self, helper, tmp_path, caplog):
bad = tmp_path / "bad.png"
bad.write_bytes(b"not an image")
with caplog.at_level(logging.ERROR):
assert helper.load_logo("BAD", bad) is None
assert "Error loading logo" in caplog.text
class TestCacheManagement:
def test_lru_evicts_oldest(self, tmp_path):
helper = LogoHelper(64, 32, cache_size=2, logger=MagicMock())
paths = [write_logo(tmp_path / f"T{i}.png") for i in range(3)]
for i, path in enumerate(paths):
helper.load_logo(f"T{i}", path)
assert len(helper._logo_cache) == 2
assert not any(k.startswith("T0_") for k in helper._logo_cache)
def test_cache_hit_refreshes_lru_position(self, tmp_path):
helper = LogoHelper(64, 32, cache_size=2, logger=MagicMock())
a, b, c = [write_logo(tmp_path / f"{n}.png") for n in ("A", "B", "C")]
helper.load_logo("A", a)
helper.load_logo("B", b)
helper.load_logo("A", a) # A is now most-recently used
helper.load_logo("C", c) # evicts B, not A
assert any(k.startswith("A_") for k in helper._logo_cache)
assert not any(k.startswith("B_") for k in helper._logo_cache)
def test_clear_cache_empties_both_structures(self, helper, tmp_path):
helper.load_logo("PHI", write_logo(tmp_path / "PHI.png"))
helper.clear_cache()
assert helper._logo_cache == {}
assert helper._cache_order == []
def test_cache_stats(self, tmp_path):
helper = LogoHelper(64, 32, cache_size=4, logger=MagicMock())
helper.load_logo("PHI", write_logo(tmp_path / "PHI.png"))
stats = helper.get_cache_stats()
assert stats["cached_logos"] == 1
assert stats["cache_size_limit"] == 4
assert stats["cache_usage_percent"] == 25
def test_zero_cache_size_does_not_divide_by_zero(self):
# Regression: this raised ZeroDivisionError.
stats = LogoHelper(64, 32, cache_size=0, logger=MagicMock()).get_cache_stats()
assert stats["cache_usage_percent"] == 0
assert stats["cache_size_limit"] == 0
class TestLoadLogoWithDownload:
def test_existing_file_skips_download(self, helper, tmp_path):
path = write_logo(tmp_path / "PHI.png")
helper.session.get = MagicMock()
assert helper.load_logo_with_download("PHI", path, "http://x/logo.png") is not None
helper.session.get.assert_not_called()
def test_downloads_then_loads(self, helper, tmp_path):
path = tmp_path / "PHI.png"
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
logo = helper.load_logo_with_download("PHI", path, "http://x/logo.png")
assert logo is not None
assert path.exists()
# stream=True is load-bearing: it is what lets the size cap apply
# before the body is buffered.
helper.session.get.assert_called_once_with(
"http://x/logo.png", timeout=30, stream=True)
def test_download_failure_falls_back_to_placeholder(self, helper, tmp_path):
helper.session.get = MagicMock(
side_effect=requests.RequestException("connection reset"))
logo = helper.load_logo_with_download(
"PHI", tmp_path / "PHI.png", "http://x/logo.png",
max_width=20, max_height=20)
assert logo is not None and logo.size == (20, 20) # placeholder
def test_http_error_falls_back_to_placeholder(self, helper, tmp_path):
response = fake_response(b"")
response.raise_for_status.side_effect = requests.HTTPError("404")
helper.session.get = MagicMock(return_value=response)
logo = helper.load_logo_with_download(
"PHI", tmp_path / "PHI.png", "http://x/logo.png",
max_width=20, max_height=20)
assert logo is not None and logo.size == (20, 20)
def test_no_url_and_no_file_gives_placeholder(self, helper, tmp_path):
logo = helper.load_logo_with_download(
"PHI", tmp_path / "missing.png", None, max_width=16, max_height=16)
assert logo is not None and logo.size == (16, 16)
class TestDownloadLogo:
def test_writes_file_and_sets_permissions(self, helper, tmp_path):
path = tmp_path / "assets" / "PHI.png"
# Directory creation is ensure_directory_permissions' job, and the
# autouse fixture stubs it out — so make the directory here.
path.parent.mkdir()
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
with patch("src.common.logo_helper.ensure_directory_permissions") as dirs, \
patch("src.common.logo_helper.ensure_file_permissions") as files:
helper._download_logo("http://x/logo.png", path)
assert path.exists()
dirs.assert_called_once()
files.assert_called_once()
assert dirs.call_args[0][0] == path.parent
def test_oversized_response_is_rejected_without_writing(self, helper, tmp_path):
# Regression: an unbounded response.content was written straight to
# disk, so a hostile URL chose how many bytes landed in assets/.
path = tmp_path / "huge.png"
helper.session.get = MagicMock(
return_value=fake_response(b"\x00" * (MAX_LOGO_BYTES + 1)))
with pytest.raises(ValueError, match="exceeds the"):
helper._download_logo("http://x/huge.png", path)
assert not path.exists()
def test_unbounded_response_is_aborted_at_the_cap(self, helper, tmp_path):
# Regression: the cap used to be checked against response.content,
# which buffers the whole body first — so a server that omits
# Content-Length and never stops sending exhausted memory before
# the check could run. Streaming counts bytes as they arrive, so
# this terminates instead of hanging.
path = tmp_path / "endless.png"
helper.session.get = MagicMock(return_value=endless_response())
with pytest.raises(ValueError, match="exceeds the"):
helper._download_logo("http://x/endless.png", path)
assert not path.exists()
def test_no_partial_file_is_left_when_the_stream_dies(self, helper, tmp_path):
# A transfer that fails midway must not leave a truncated logo
# where the real one belongs — load_logo() would cache it.
path = tmp_path / "cut.png"
real = png_bytes()
def _dies_midway(*_args, **_kwargs):
yield real[:20]
raise OSError("connection reset")
response = MagicMock()
response.__enter__.return_value = response
response.__exit__.return_value = False
response.raise_for_status = MagicMock()
response.iter_content = _dies_midway
helper.session.get = MagicMock(return_value=response)
with pytest.raises(OSError):
helper._download_logo("http://x/cut.png", path)
assert not path.exists()
assert list(tmp_path.glob("*.part")) == []
def test_concurrent_downloads_do_not_share_a_temp_file(self, helper, tmp_path):
# Two plugins can ask for the same logo at once. A fixed
# "<name>.part" would let them interleave writes into one file and
# publish the mixture; each download gets its own temp name.
path = tmp_path / "PHI.png"
seen = []
real_mkstemp = tempfile.mkstemp
def record(*args, **kwargs):
fd, name = real_mkstemp(*args, **kwargs)
seen.append(name)
return fd, name
with patch("src.common.logo_helper.tempfile.mkstemp", side_effect=record):
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
helper._download_logo("http://x/logo.png", path)
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
helper._download_logo("http://x/logo.png", path)
assert len(seen) == 2 and seen[0] != seen[1]
assert path.exists()
assert list(tmp_path.glob("*.part")) == [] # both cleaned up
def test_request_failure_leaves_no_temp_file(self, helper, tmp_path):
# mkstemp creates the file up front, so an error before any bytes
# arrive still has something to clean up.
helper.session.get = MagicMock(
side_effect=requests.RequestException("connection reset"))
with pytest.raises(requests.RequestException):
helper._download_logo("http://x/logo.png", tmp_path / "PHI.png")
assert list(tmp_path.glob("*")) == []
def test_non_image_response_is_deleted_and_raises(self, helper, tmp_path):
# Regression: undecodable bytes stayed on disk, so every later
# load_logo() call hit the corrupt file instead of re-downloading.
path = tmp_path / "bad.png"
helper.session.get = MagicMock(return_value=fake_response(b"<html>404</html>"))
# Specifically Pillow's identify failure, not any OSError: the
# point is that the bytes did not decode, and OSError alone would
# also admit unrelated filesystem faults.
with pytest.raises(UnidentifiedImageError):
helper._download_logo("http://x/bad.png", path)
assert not path.exists()
assert list(tmp_path.glob("*.part")) == []
def test_decompression_bomb_is_deleted_and_raises(self, helper, tmp_path, monkeypatch):
path = tmp_path / "bomb.png"
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
class Bomb:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def load(self):
raise Image.DecompressionBombError("too many pixels")
monkeypatch.setattr("src.common.logo_helper.Image.open", lambda *a, **kw: Bomb())
with pytest.raises(Image.DecompressionBombError):
helper._download_logo("http://x/bomb.png", path)
assert not path.exists()
def test_bad_download_surfaces_as_placeholder_not_crash(self, helper, tmp_path):
# The new guards raise, and load_logo_with_download's existing
# broad except turns that into the placeholder path.
helper.session.get = MagicMock(return_value=fake_response(b"garbage"))
logo = helper.load_logo_with_download(
"PHI", tmp_path / "PHI.png", "http://x/bad.png",
max_width=12, max_height=12)
assert logo is not None and logo.size == (12, 12)
class TestLogoVariations:
def test_plain_abbreviation_returns_itself(self, helper):
assert helper.get_logo_variations("PHI") == ["PHI"]
def test_ampersand_expanded(self, helper):
assert "TAAND M" in helper.get_logo_variations("TA& M")
def test_and_contracted(self, helper):
assert "T&M" in helper.get_logo_variations("TANDM")
def test_special_case_appends_known_aliases(self, helper):
variations = helper.get_logo_variations("TA&M")
assert "TAMU" in variations and "TEXASAM" in variations
assert "TAANDM" in variations # the generic & rule still applies
class TestNormalizeAbbreviation:
def test_uppercases_and_strips(self, helper):
assert helper.normalize_abbreviation(" phi ") == "PHI"
def test_ampersand_becomes_and(self, helper):
assert helper.normalize_abbreviation("TA&M") == "TAANDM"
def test_internal_spaces_removed(self, helper):
assert helper.normalize_abbreviation("New York") == "NEWYORK"
def test_deliberately_differs_from_logo_downloader(self, helper):
# Pinned, not a bug: LogoDownloader.normalize_abbreviation replaces
# filesystem-unsafe characters but keeps spaces, and plugins call
# that one. Changing either changes which logo filenames resolve on
# existing installs. Both docstrings say so explicitly.
from src.logo_downloader import LogoDownloader
assert helper.normalize_abbreviation("New York") == "NEWYORK"
assert LogoDownloader.normalize_abbreviation("New York") == "NEW YORK"
class TestPlaceholderLogo:
def test_uses_requested_dimensions(self, helper):
assert helper._create_placeholder_logo("PHI", 30, 20).size == (30, 20)
def test_defaults_to_one_and_a_half_display(self, helper):
assert helper._create_placeholder_logo("PHI").size == (96, 48)
def test_is_rgba(self, helper):
assert helper._create_placeholder_logo("PHI", 10, 10).mode == "RGBA"
def test_invalid_dimensions_return_none(self, helper, caplog):
with caplog.at_level(logging.ERROR):
assert helper._create_placeholder_logo("PHI", -5, -5) is None
assert "Error creating placeholder" in caplog.text
class TestSessionConfiguration:
def test_user_agent_and_accept_headers(self, helper):
assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0"
assert helper.session.headers["Accept"] == "image/*"
+162
View File
@@ -0,0 +1,162 @@
"""Tests that a slow ESPN cannot take a whole plugin update with it.
Odds are fetched per live game from inside SportsLive.update(), with show_odds
defaulting on, and the plugin executor kills an operation at 30s. The odds
request timeout was also 30s, so one stalled request consumed the entire budget
and the update carrying every game's score was killed:
00:43:43 ERROR plugin football-scoreboard operation timed out after 30.0s
01:43:43 ERROR plugin football-scoreboard operation timed out after 30.0s
Invisible out of season -- preseason week 1 returns a single game -- and a
Sunday slate is around sixteen.
The request now goes through a session that identifies the caller, so the
tests patch `manager.session.get` rather than the module's `requests.get`.
"""
from unittest.mock import Mock
import requests
from src.base_odds_manager import BaseOddsManager
PLUGIN_BUDGET = 30.0 # PluginExecutor(default_timeout=30.0)
def _manager(cache=None):
cache = cache or Mock()
cache.get_with_auto_strategy.return_value = None
return BaseOddsManager(cache_manager=cache, config_manager=None)
def _timing_out(manager):
"""Point the manager's session at a request that always times out."""
manager.session.get = Mock(side_effect=requests.exceptions.Timeout("x"))
return manager.session.get
def _returning(manager, payload):
resp = Mock()
resp.json.return_value = payload
resp.raise_for_status.return_value = None
manager.session.get = Mock(return_value=resp)
return manager.session.get
class TestRequestTimeout:
def test_leaves_room_in_the_operation_budget(self):
assert _manager().request_timeout < PLUGIN_BUDGET / 2
def test_the_timeout_is_the_one_actually_used(self):
m = _manager()
get = _timing_out(m)
m.get_odds("football", "nfl", "401")
assert get.call_args.kwargs["timeout"] == m.request_timeout
class TestIdentifiesItselfToEspn:
"""ESPN 403s python-requests' default agent, and bare custom tokens.
What it accepts is a token carrying a URL that says who is calling. This
path used a bare requests.get and so sent the default -- the one thing
known to be rejected. Everything else in the tree that talks to ESPN
already sends the header below.
"""
def test_the_user_agent_names_the_project_and_links_to_it(self):
ua = _manager().session.headers["User-Agent"]
assert "python-requests" not in ua
assert "LEDMatrix" in ua
assert "github.com/ChuckBuilds/LEDMatrix" in ua
def test_it_is_the_same_agent_the_rest_of_the_tree_sends(self):
# Compared against the live value rather than a copied literal, so the
# two cannot drift apart the next time ESPN moves the goalposts.
from src.common.api_helper import APIHelper
assert (_manager().session.headers["User-Agent"]
== APIHelper().session.headers["User-Agent"])
def test_the_header_reaches_the_request(self):
m = _manager()
get = _returning(m, {})
m._extract_espn_data = Mock(return_value=None)
m.get_odds("football", "nfl", "401")
# Sent via the session, so it applies without being passed per-call.
assert get.call_count == 1
assert "User-Agent" in m.session.headers
def test_no_retry_adapter_multiplies_the_timeout(self):
# api_helper mounts a retrying adapter; this path must not, or a 5s
# timeout becomes 15s and the budget fix is undone.
m = _manager()
for adapter in m.session.adapters.values():
retries = getattr(adapter, "max_retries", None)
assert getattr(retries, "total", 0) in (0, None), (
"odds session mounts a retrying adapter (total=%r); retries "
"multiply request_timeout" % getattr(retries, "total", None))
class TestSlowEspnCannotKillTheUpdate:
def test_one_failure_stops_the_rest_of_the_slate_hitting_the_network(self):
m = _manager()
get = _timing_out(m)
for i in range(16): # a full slate, one game at a time
m.get_odds("football", "nfl", "4018730%02d" % i)
assert get.call_count == 1, (
"%d games each paid the timeout; the breaker should have stopped "
"after the first" % get.call_count)
def test_worst_case_slate_stays_inside_the_budget(self):
m = _manager()
assert m.request_timeout * 1 < PLUGIN_BUDGET
def test_recovery_is_automatic(self):
m = _manager()
import src.base_odds_manager as mod
real_monotonic = mod.time.monotonic
clock = {"t": 1000.0}
try:
mod.time.monotonic = lambda: clock["t"]
get = _timing_out(m)
m.get_odds("football", "nfl", "401")
assert m._skip_network_until > clock["t"], "breaker did not open"
clock["t"] += 1
before = get.call_count
m.get_odds("football", "nfl", "402")
assert get.call_count == before, "should not have retried"
clock["t"] += m._FAILURE_COOLDOWN
m.get_odds("football", "nfl", "403")
assert get.call_count > before, "never retried"
finally:
mod.time.monotonic = real_monotonic
def test_a_healthy_fetch_clears_the_breaker(self):
m = _manager()
m._skip_network_until = 0.0
m._extract_espn_data = Mock(return_value=None)
_returning(m, {})
m.get_odds("football", "nfl", "401")
assert m._skip_network_until == 0.0
def test_a_403_opens_the_breaker_rather_than_hammering(self):
# raise_for_status raises HTTPError, a RequestException -- so a wrong
# or missing agent backs off instead of 403ing once per game.
m = _manager()
resp = Mock()
resp.raise_for_status.side_effect = requests.exceptions.HTTPError("403")
m.session.get = Mock(return_value=resp)
m.get_odds("football", "nfl", "401")
assert m._skip_network_until > 0.0
def test_the_stale_cache_fallback_still_works(self):
# The failing request must still hand back whatever was cached; only
# the *subsequent* games skip the network.
cache = Mock()
cache.get_with_auto_strategy.side_effect = [None, {"details": "stale"}]
m = BaseOddsManager(cache_manager=cache, config_manager=None)
_timing_out(m)
assert m.get_odds("football", "nfl", "401") == {"details": "stale"}
+240
View File
@@ -0,0 +1,240 @@
"""
Tests for src/plugin_system/saved_repositories.py pins the
SavedRepositoriesManager contract.
Covers: the three accepted on-disk load shapes (bare list, wrapped
{"repositories": [...]}, anything else -> []) and that saves always write
the bare-list form; add/remove/has round trips through a fresh manager;
URL normalization post-fix (_clean_url strips only a TRAILING '.git' after
trailing slashes the old unanchored .replace('.git', '') mangled URLs
like my.github.io); name derivation and registry-vs-single type
classification (the ledmatrix-plugins check is lowercased, the
plugins.json check is case-sensitive); and the post-fix rollback of the
in-memory list when _save_repositories() fails, so memory never diverges
from disk.
"""
import json
from src.plugin_system.saved_repositories import SavedRepositoriesManager
def make_manager(path):
return SavedRepositoriesManager(config_path=str(path))
class TestLoading:
def test_missing_file_empty_and_not_created(self, tmp_path):
path = tmp_path / "repos.json"
manager = make_manager(path)
assert manager.get_all() == []
assert not path.exists()
def test_bare_list_shape(self, tmp_path):
path = tmp_path / "repos.json"
entries = [{'url': 'https://github.com/u/r', 'name': 'r', 'type': 'single'}]
path.write_text(json.dumps(entries))
assert make_manager(path).get_all() == entries
def test_wrapped_dict_shape(self, tmp_path):
path = tmp_path / "repos.json"
entries = [{'url': 'https://github.com/u/r', 'name': 'r', 'type': 'single'}]
path.write_text(json.dumps({'repositories': entries}))
assert make_manager(path).get_all() == entries
def test_other_shape_yields_empty(self, tmp_path):
path = tmp_path / "repos.json"
path.write_text(json.dumps({'x': 1}))
assert make_manager(path).get_all() == []
def test_malformed_json_yields_empty_no_raise(self, tmp_path):
path = tmp_path / "repos.json"
path.write_text("not json {{")
assert make_manager(path).get_all() == []
class TestSaveFormat:
def test_save_always_writes_bare_list(self, tmp_path):
# Even when loaded from the wrapped {"repositories": [...]} form,
# the next save normalizes the file to a bare JSON list.
path = tmp_path / "repos.json"
entries = [{'url': 'https://github.com/u/r', 'name': 'r', 'type': 'single'}]
path.write_text(json.dumps({'repositories': entries}))
manager = make_manager(path)
assert manager.add("https://github.com/u/r2") is True
on_disk = json.loads(path.read_text())
assert isinstance(on_disk, list)
assert len(on_disk) == 2
class TestAdd:
def test_round_trip_creates_parents_and_reloads(self, tmp_path):
path = tmp_path / "sub" / "repos.json"
manager = make_manager(path)
assert manager.add("https://github.com/user/repo") is True
assert path.exists()
entry = manager.get_all()[0]
assert entry['url'] == "https://github.com/user/repo"
assert entry['name'] == "repo"
assert entry['type'] == "single"
# A fresh manager on the same path sees the persisted entry.
fresh = make_manager(path)
assert fresh.get_all() == [entry]
def test_duplicate_returns_false_file_unchanged(self, tmp_path):
path = tmp_path / "repos.json"
manager = make_manager(path)
assert manager.add("https://github.com/user/repo") is True
before = path.read_text()
assert manager.add("https://github.com/user/repo") is False
assert path.read_text() == before
assert len(manager.get_all()) == 1
def test_trailing_git_and_slash_stripped(self, tmp_path):
manager = make_manager(tmp_path / "repos.json")
assert manager.add("https://github.com/user/repo.git/") is True
assert manager.get_all()[0]['url'] == "https://github.com/user/repo"
def test_interior_dot_git_not_mangled(self, tmp_path):
# Regression for the old unanchored .replace('.git', ''): a URL
# merely CONTAINING '.git' must be stored verbatim.
manager = make_manager(tmp_path / "repos.json")
url = "https://github.com/user/my.github.io"
assert manager.add(url) is True
assert manager.get_all()[0]['url'] == url
class TestNameExtraction:
def test_name_derived_from_last_path_segment(self, tmp_path):
manager = make_manager(tmp_path / "repos.json")
manager.add("https://github.com/user/football-scoreboard")
assert manager.get_all()[0]['name'] == "football-scoreboard"
def test_explicit_name_preserved(self, tmp_path):
manager = make_manager(tmp_path / "repos.json")
manager.add("https://github.com/user/repo", name="My Repo")
assert manager.get_all()[0]['name'] == "My Repo"
def test_url_without_slash_uses_whole_url(self, tmp_path):
manager = make_manager(tmp_path / "repos.json")
manager.add("standalone")
assert manager.get_all()[0]['name'] == "standalone"
class TestTypeClassification:
def _type_of(self, tmp_path, url):
manager = make_manager(tmp_path / "repos.json")
assert manager.add(url) is True
return manager.get_all()[0]['type']
def test_plugins_json_url_is_registry(self, tmp_path):
url = "https://raw.githubusercontent.com/x/main/plugins.json"
assert self._type_of(tmp_path, url) == "registry"
def test_ledmatrix_plugins_check_is_case_insensitive(self, tmp_path):
url = "https://github.com/ChuckBuilds/LEDMATRIX-PLUGINS"
assert self._type_of(tmp_path, url) == "registry"
def test_plugins_json_check_is_case_sensitive(self, tmp_path):
# Only the 'ledmatrix-plugins' check is lowercased; the
# 'plugins.json' substring check is case-sensitive. Pinned.
url = "https://example.com/PLUGINS.JSON"
assert self._type_of(tmp_path, url) == "single"
def test_plain_repo_is_single(self, tmp_path):
assert self._type_of(tmp_path, "https://github.com/user/repo") == "single"
def test_get_registry_repositories_filters(self, tmp_path):
manager = make_manager(tmp_path / "repos.json")
manager.add("https://github.com/user/repo")
manager.add("https://raw.githubusercontent.com/x/main/plugins.json")
registries = manager.get_registry_repositories()
assert len(registries) == 1
assert registries[0]['type'] == "registry"
class TestRemove:
def test_remove_present_persists(self, tmp_path):
path = tmp_path / "repos.json"
manager = make_manager(path)
manager.add("https://github.com/user/repo")
assert manager.remove("https://github.com/user/repo") is True
assert manager.get_all() == []
assert make_manager(path).get_all() == []
def test_remove_absent_false_no_write(self, tmp_path):
path = tmp_path / "repos.json"
manager = make_manager(path)
manager.add("https://github.com/user/repo")
before = path.read_text()
assert manager.remove("https://github.com/user/other") is False
assert path.read_text() == before
def test_remove_with_dirty_url_matches_clean_stored(self, tmp_path):
manager = make_manager(tmp_path / "repos.json")
manager.add("https://github.com/user/repo")
assert manager.remove("https://github.com/user/repo.git/") is True
assert manager.get_all() == []
class TestHas:
def test_has_applies_url_cleaning(self, tmp_path):
manager = make_manager(tmp_path / "repos.json")
manager.add("https://x/y")
assert manager.has("https://x/y.git/") is True
assert manager.has("https://x/z") is False
class TestSaveFailureRollback:
def test_add_rolls_back_on_save_failure(self, tmp_path, monkeypatch):
# Post-fix: a failed save must not leave a phantom in-memory entry.
path = tmp_path / "repos.json"
manager = make_manager(path)
monkeypatch.setattr(manager, "_save_repositories", lambda: False)
assert manager.add("https://github.com/user/repo") is False
assert manager.get_all() == []
assert not path.exists()
def test_remove_rolls_back_on_save_failure(self, tmp_path, monkeypatch):
path = tmp_path / "repos.json"
manager = make_manager(path)
manager.add("https://github.com/user/repo") # real save
monkeypatch.setattr(manager, "_save_repositories", lambda: False)
assert manager.remove("https://github.com/user/repo") is False
assert manager.get_all() == [
{'url': 'https://github.com/user/repo', 'name': 'repo', 'type': 'single'}
]
# Disk still has the entry too — memory and disk stay in sync.
assert len(json.loads(path.read_text())) == 1
def test_failed_write_leaves_existing_file_intact(self, tmp_path, monkeypatch):
# The save is atomic (temp file + os.replace): a write that dies
# mid-serialization must neither truncate the existing file nor
# leave a stray .tmp behind.
path = tmp_path / "repos.json"
manager = make_manager(path)
manager.add("https://github.com/user/repo") # real save
before = path.read_text()
def boom(*args, **kwargs):
raise OSError("disk full")
monkeypatch.setattr(json, "dump", boom)
assert manager.add("https://github.com/user/other") is False
assert path.read_text() == before
assert list(tmp_path.glob("*.tmp")) == []
class TestGetAllCopy:
def test_get_all_is_shallow_copy(self, tmp_path):
# Characterization: get_all() copies the LIST but not the entry
# dicts, so mutating a returned entry mutates internal state.
# Appending to the returned list, however, does not. Do not "fix"
# without auditing callers that rely on list-copy semantics.
manager = make_manager(tmp_path / "repos.json")
manager.add("https://github.com/user/repo")
returned = manager.get_all()
returned.append({'url': 'x'})
assert len(manager.get_all()) == 1 # list itself is copied
manager.get_all()[0]['name'] = 'hacked'
assert manager.get_all()[0]['name'] == 'hacked' # dicts are shared
+377
View File
@@ -0,0 +1,377 @@
"""Gap tests for src/skin_system/skin_runtime.py: the discovery cache,
module namespacing internals, API gating edge cases, and targeting.
test/test_skin_system.py already covers discovery validation, load_skin
basics, and build_context nothing here duplicates those.
NOTE: every test uses a UNIQUE skin id. load_skin caches the entry
module in sys.modules per skin id and never re-executes it, so reusing
an id across tests would silently serve another test's module.
"""
import builtins
import json
import os
import sys
import time
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# skin_runtime -> skin_base can transitively reach hardware modules via
# sports imports in sibling tests' processes; stub the matrix driver
# before importing, matching test_skin_system.py.
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.skin_system import skin_runtime
from src.skin_system.skin_base import SKIN_API_VERSION, ScoreboardSkin
DEFAULT_BODY = (
"from src.skin_system.skin_base import ScoreboardSkin\n"
"class {cls}(ScoreboardSkin):\n"
" def render_live(self, ctx, game):\n"
" return True\n"
)
@pytest.fixture(autouse=True)
def _clean_runtime_state():
"""Clear the discovery cache and any skin modules this test creates."""
skin_runtime._discovery_cache.clear()
before = {k for k in sys.modules if k.startswith("_skin_")}
yield
skin_runtime._discovery_cache.clear()
created = [k for k in sys.modules
if k.startswith("_skin_") and k not in before]
for k in created:
sys.modules.pop(k, None)
def make_skin(skins_dir: Path, skin_id: str, *,
api_version: str = SKIN_API_VERSION,
class_name: str = "TestSkin",
body: str = None,
extra_files: dict = None,
entry_point: str = None,
manifest_id: str = None,
manifest_extra: dict = None,
write_entry: bool = True) -> Path:
"""Write a skin package directory and return its path."""
skin_dir = skins_dir / skin_id
skin_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"id": manifest_id or skin_id,
"name": skin_id,
"version": "1.0.0",
"skin_api_version": api_version,
"class_name": class_name,
}
if entry_point:
manifest["entry_point"] = entry_point
manifest.update(manifest_extra or {})
(skin_dir / "skin.json").write_text(json.dumps(manifest))
if write_entry:
entry_name = entry_point or "skin.py"
(skin_dir / entry_name).write_text(
body if body is not None else DEFAULT_BODY.format(cls=class_name))
for name, content in (extra_files or {}).items():
(skin_dir / name).write_text(content)
return skin_dir
def counting_read_manifest(monkeypatch):
"""Wrap skin_runtime._read_manifest with a call counter."""
original = skin_runtime._read_manifest
counter = {"count": 0}
def wrapper(skin_dir):
counter["count"] += 1
return original(skin_dir)
monkeypatch.setattr(skin_runtime, "_read_manifest", wrapper)
return counter
def bump_mtime(path: Path, offset: float = 100.0):
"""Set a distinct, strictly later mtime so the fingerprint changes."""
t = time.time() + offset
os.utime(path, (t, t))
# ---------------------------------------------------------------------------
# A. Discovery cache
# ---------------------------------------------------------------------------
class TestDiscoveryCache:
def test_second_call_serves_cache(self, tmp_path, monkeypatch):
make_skin(tmp_path, "t01-cache-hit")
counter = counting_read_manifest(monkeypatch)
first = skin_runtime.discover_skins(tmp_path)
count_after_first = counter["count"]
assert count_after_first >= 1
second = skin_runtime.discover_skins(tmp_path)
assert counter["count"] == count_after_first # no re-read
assert second == first
assert "t01-cache-hit" in second
def test_manifest_edit_invalidates_without_force_refresh(self, tmp_path):
skin_dir = make_skin(tmp_path, "t02-edit")
skins = skin_runtime.discover_skins(tmp_path)
assert skins["t02-edit"]["name"] == "t02-edit"
manifest_path = skin_dir / "skin.json"
manifest = json.loads(manifest_path.read_text())
manifest["name"] = "renamed"
manifest_path.write_text(json.dumps(manifest))
bump_mtime(manifest_path)
skins = skin_runtime.discover_skins(tmp_path) # no force_refresh
assert skins["t02-edit"]["name"] == "renamed"
def test_new_skin_dir_invalidates(self, tmp_path):
make_skin(tmp_path, "t03-first")
assert set(skin_runtime.discover_skins(tmp_path)) == {"t03-first"}
new_dir = make_skin(tmp_path, "t03-second")
bump_mtime(new_dir / "skin.json")
bump_mtime(tmp_path)
skins = skin_runtime.discover_skins(tmp_path) # no force_refresh
assert set(skins) == {"t03-first", "t03-second"}
def test_py_file_change_does_not_invalidate(self, tmp_path, monkeypatch):
# PIN: the fingerprint only globs */skin.json — editing a skin's
# .py file alone does NOT invalidate the cache; the cached
# manifests are still served (a code change needs a restart).
skin_dir = make_skin(tmp_path, "t04-pyedit")
counter = counting_read_manifest(monkeypatch)
skin_runtime.discover_skins(tmp_path)
count_after_first = counter["count"]
(skin_dir / "skin.py").write_text("# rewritten\n" +
DEFAULT_BODY.format(cls="TestSkin"))
bump_mtime(skin_dir / "skin.py")
skins = skin_runtime.discover_skins(tmp_path)
assert counter["count"] == count_after_first # cache still served
assert "t04-pyedit" in skins
def test_force_refresh_rereads_with_unchanged_fingerprint(self, tmp_path,
monkeypatch):
make_skin(tmp_path, "t05-force")
counter = counting_read_manifest(monkeypatch)
skin_runtime.discover_skins(tmp_path)
count_after_first = counter["count"]
skin_runtime.discover_skins(tmp_path, force_refresh=True)
assert counter["count"] > count_after_first
def test_result_mapping_is_copy_but_manifests_shared(self, tmp_path):
make_skin(tmp_path, "t06-copy")
result = skin_runtime.discover_skins(tmp_path)
# Mutating the returned mapping does not poison the cache...
del result["t06-copy"]
again = skin_runtime.discover_skins(tmp_path) # cache hit
assert "t06-copy" in again
# ...but the inner manifest dicts ARE shared with the cache (pin).
again["t06-copy"]["name"] = "mutated-inner"
third = skin_runtime.discover_skins(tmp_path) # cache hit
assert third["t06-copy"]["name"] == "mutated-inner"
def test_missing_directory_returns_empty_and_caches_nothing(self, tmp_path):
missing = tmp_path / "not-yet"
assert skin_runtime.discover_skins(missing) == {}
assert str(missing) not in skin_runtime._discovery_cache
# Creating the directory later is picked up without force_refresh.
make_skin(missing, "t07-late")
skins = skin_runtime.discover_skins(missing)
assert "t07-late" in skins
def test_hidden_underscore_and_plain_file_entries_skipped(self, tmp_path):
make_skin(tmp_path, ".hidden-skin")
make_skin(tmp_path, "_private-skin")
(tmp_path / "stray-file").write_text("not a directory")
make_skin(tmp_path, "t08-good")
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
assert set(skins) == {"t08-good"}
def test_manifest_id_mismatch_keys_by_manifest_id(self, tmp_path):
make_skin(tmp_path, "t09-dirname", manifest_id="t09-manifest-id")
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
assert "t09-manifest-id" in skins
assert "t09-dirname" not in skins
assert skins["t09-manifest-id"]["_skin_dir"].endswith("t09-dirname")
def test_falsy_required_field_drops_skin(self, tmp_path):
make_skin(tmp_path, "t10-empty-class", class_name="")
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
assert skins == {}
# ---------------------------------------------------------------------------
# B. Module namespacing (_load_skin_module via load_skin)
# ---------------------------------------------------------------------------
BODY_WITH_HELPERS = (
"import helpers\n"
"from src.skin_system.skin_base import ScoreboardSkin\n"
"class TestSkin(ScoreboardSkin):\n"
" pass\n"
)
class TestModuleNamespacing:
def test_namespaced_sys_modules_keys(self, tmp_path):
make_skin(tmp_path, "t11-ns", body=BODY_WITH_HELPERS,
extra_files={"helpers.py": "VALUE = 11\n"})
skin = skin_runtime.load_skin("t11-ns", skins_dir=tmp_path)
assert skin is not None
assert "_skin_t11-ns_skin" in sys.modules
assert "_skin_t11-ns_helpers" in sys.modules
def test_preseeded_bare_name_restored(self, tmp_path, monkeypatch):
sentinel = object()
monkeypatch.setitem(sys.modules, "helpers", sentinel)
make_skin(tmp_path, "t12a-restore", body=BODY_WITH_HELPERS,
extra_files={"helpers.py": "VALUE = 'a'\n"})
skin = skin_runtime.load_skin("t12a-restore", skins_dir=tmp_path)
assert skin is not None
assert sys.modules["helpers"] is sentinel
def test_absent_bare_name_stays_absent(self, tmp_path):
saved = sys.modules.pop("helpers", None)
try:
assert "helpers" not in sys.modules
make_skin(tmp_path, "t12b-absent", body=BODY_WITH_HELPERS,
extra_files={"helpers.py": "VALUE = 'b'\n"})
skin = skin_runtime.load_skin("t12b-absent", skins_dir=tmp_path)
assert skin is not None
assert "helpers" not in sys.modules
finally:
if saved is not None:
sys.modules["helpers"] = saved
def test_stdlib_shadowing_sibling_leaves_real_module_intact(self, tmp_path):
real_json = sys.modules["json"]
make_skin(tmp_path, "t12c-json",
extra_files={"json.py": "SKIN_LOCAL = True\n"})
skin = skin_runtime.load_skin("t12c-json", skins_dir=tmp_path)
assert skin is not None
assert sys.modules["json"] is real_json
assert not hasattr(sys.modules["json"], "SKIN_LOCAL")
assert json.loads('{"ok": 1}') == {"ok": 1} # stdlib still works
# The skin's copy lives only under its namespaced alias.
assert getattr(sys.modules["_skin_t12c-json_json"], "SKIN_LOCAL") is True
def test_entry_module_executed_once_across_loads(self, tmp_path,
monkeypatch):
executions = []
monkeypatch.setattr(builtins, "_t13_skin_executions", executions,
raising=False)
body = (
"import builtins\n"
"builtins._t13_skin_executions.append(1)\n"
"from src.skin_system.skin_base import ScoreboardSkin\n"
"class TestSkin(ScoreboardSkin):\n"
" pass\n"
)
make_skin(tmp_path, "t13-cached", body=body)
for _ in range(3):
skin = skin_runtime.load_skin("t13-cached", skins_dir=tmp_path)
assert skin is not None
assert len(executions) == 1 # module executed exactly once
def test_sibling_import_failure_returns_none_and_restores_bare(
self, tmp_path, monkeypatch):
sentinel = object()
monkeypatch.setitem(sys.modules, "helpers", sentinel)
make_skin(tmp_path, "t14-sibfail", body=BODY_WITH_HELPERS,
extra_files={"helpers.py": "raise RuntimeError('sibling boom')\n"})
assert skin_runtime.load_skin("t14-sibfail", skins_dir=tmp_path) is None
assert sys.modules["helpers"] is sentinel
def test_missing_entry_point_file(self, tmp_path):
make_skin(tmp_path, "t15-noentry", write_entry=False)
assert skin_runtime.load_skin("t15-noentry", skins_dir=tmp_path) is None
def test_custom_entry_point(self, tmp_path):
make_skin(tmp_path, "t16-custom", entry_point="render.py")
skin = skin_runtime.load_skin("t16-custom", skins_dir=tmp_path)
assert isinstance(skin, ScoreboardSkin)
assert "_skin_t16-custom_render" in sys.modules
assert "_skin_t16-custom_skin" not in sys.modules
def test_class_name_pointing_at_unrelated_class(self, tmp_path):
body = "class NotASkin:\n pass\n"
make_skin(tmp_path, "t17a-wrongclass", body=body,
class_name="NotASkin")
assert skin_runtime.load_skin("t17a-wrongclass",
skins_dir=tmp_path) is None
def test_class_name_pointing_at_instance(self, tmp_path):
body = (
"from src.skin_system.skin_base import ScoreboardSkin\n"
"class MySkin(ScoreboardSkin):\n"
" pass\n"
"obj = MySkin({}, {})\n"
)
make_skin(tmp_path, "t17b-instance", body=body, class_name="obj")
assert skin_runtime.load_skin("t17b-instance",
skins_dir=tmp_path) is None
def test_constructor_raising_returns_none(self, tmp_path):
body = (
"from src.skin_system.skin_base import ScoreboardSkin\n"
"class TestSkin(ScoreboardSkin):\n"
" def __init__(self, manifest, options):\n"
" raise ValueError('ctor boom')\n"
)
make_skin(tmp_path, "t18-ctor", body=body)
assert skin_runtime.load_skin("t18-ctor", skins_dir=tmp_path) is None
# ---------------------------------------------------------------------------
# C. API gate + targeting
# ---------------------------------------------------------------------------
class TestApiGateAndTargeting:
def test_same_major_higher_minor_loads(self, tmp_path):
make_skin(tmp_path, "t19-minor", api_version="1.9.0")
skin = skin_runtime.load_skin("t19-minor", skins_dir=tmp_path)
assert isinstance(skin, ScoreboardSkin)
def test_malformed_api_version_refused(self, tmp_path):
make_skin(tmp_path, "t20-malformed", api_version="abc")
assert skin_runtime.load_skin("t20-malformed",
skins_dir=tmp_path) is None
@pytest.mark.parametrize("manifest,sport,sport_key,expected", [
# No targets key at all -> matches everything
({"id": "x"}, "baseball", "mlb", True),
({"id": "x"}, None, None, True),
# Empty targets dict -> matches everything
({"id": "x", "targets": {}}, "hockey", None, True),
# sports family match
({"id": "x", "targets": {"sports": ["baseball"]}},
"baseball", None, True),
# sport_keys exact match
({"id": "x", "targets": {"sport_keys": ["milb"]}},
None, "milb", True),
# OR semantics: sport_keys matches even though sports excludes it
({"id": "x", "targets": {"sports": ["hockey"],
"sport_keys": ["milb"]}},
"baseball", "milb", True),
# Neither matches
({"id": "x", "targets": {"sports": ["hockey"]}},
"baseball", None, False),
({"id": "x", "targets": {"sports": ["hockey"],
"sport_keys": ["nhl"]}},
"baseball", "milb", False),
])
def test_skin_matches_target(self, manifest, sport, sport_key, expected):
assert skin_runtime.skin_matches_target(
manifest, sport, sport_key) is expected
+197
View File
@@ -878,3 +878,200 @@ class TestCapabilityExports:
def test_rotation_strategy_base_requires_a_schedule(self):
with pytest.raises(NotImplementedError):
RotationStrategy().schedule([game("a")])
# ---------------------------------------------------------------------------
# Celebrations: rendering + previously untested edges
# ---------------------------------------------------------------------------
from PIL import Image, ImageDraw, ImageFont # noqa: E402
class _RenderableLive(_FakeLive):
"""A _FakeLive that can actually execute _draw_celebration_layout:
real fonts, a display manager holding a real PIL image, and the two
SportsCore drawing seams the mixin calls."""
def __init__(self, mode_config=None, favorite_teams=None,
width=128, height=32, with_matrix=True):
super().__init__(mode_config=mode_config, favorite_teams=favorite_teams)
font = ImageFont.load_default()
self.fonts = {"time": font, "status": font, "score": font}
self.display_width = width
self.display_height = height
dm = MagicMock()
if with_matrix:
dm.matrix.width = width
dm.matrix.height = height
else:
dm.matrix = None
dm.image = Image.new("RGB", (width, height))
self.display_manager = dm
self.logo_calls = []
def _load_and_resize_logo(self, team_id, abbr, path, url):
self.logo_calls.append(abbr)
logo = Image.new("RGBA", (10, 10), (0, 200, 0, 255))
return logo
def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255)):
draw.text(position, str(text), font=font, fill=fill)
class _RenderableCelebrating(CelebrationMixin, _RenderableLive):
pass
def _armed(manager, *, kind="score", side="home", started_ago=0.0):
manager._start_celebration(
game("g1", home_score=7, away_score=3), kind,
scored_side=side, team_abbr="HOM", away_score=3, home_score=7,
points=7,
)
manager.active_celebration["started_at"] = time.time() - started_ago
return manager.active_celebration
class TestDrawCelebrationLayout:
"""The takeover render path, executed for real (previously always
mocked out)."""
def test_renders_and_hands_frame_to_display_manager(self):
manager = _RenderableCelebrating()
celebration = _armed(manager)
manager._draw_celebration_layout(celebration)
# The final frame was assigned and pushed.
assert isinstance(manager.display_manager.image, Image.Image)
assert manager.display_manager.image.mode == "RGB"
assert manager.display_manager.image.size == (128, 32)
manager.display_manager.update_display.assert_called_once()
assert manager.display_manager.image.convert("L").getbbox() is not None
def test_force_clear_clears_display_first(self):
manager = _RenderableCelebrating()
celebration = _armed(manager)
manager._draw_celebration_layout(celebration, force_clear=True)
manager.display_manager.clear.assert_called_once()
def test_flash_background_within_first_window(self):
# elapsed < 1.2 with int(elapsed/0.2) even -> flash color backdrop.
manager = _RenderableCelebrating()
celebration = _armed(manager, started_ago=0.05)
manager._draw_celebration_layout(celebration)
flash = manager.display_manager.image
# After the flash window: plain black backdrop.
celebration["started_at"] = time.time() - 5
manager._draw_celebration_layout(celebration)
steady = manager.display_manager.image
# Corner pixels (away from logos/text) show the two backgrounds.
assert flash.getpixel((64, 30)) != steady.getpixel((64, 30)) or \
flash.getpixel((3, 0)) != steady.getpixel((3, 0))
def test_matrix_dims_fallback_to_display_attrs(self):
manager = _RenderableCelebrating(width=96, height=48, with_matrix=False)
celebration = _armed(manager)
manager._draw_celebration_layout(celebration)
assert manager.display_manager.image.size == (96, 48)
def test_highlight_color_alternates_with_elapsed(self):
manager = _RenderableCelebrating()
celebration = _armed(manager)
# int(elapsed*4) % 2 == 0 -> yellow; == 1 -> orange. Force each phase
# and diff the frames.
celebration["started_at"] = time.time() - 2.0 # 8 -> even
manager._draw_celebration_layout(celebration)
even = manager.display_manager.image.tobytes()
celebration["started_at"] = time.time() - 2.25 # 9 -> odd
manager._draw_celebration_layout(celebration)
odd = manager.display_manager.image.tobytes()
assert even != odd
def test_logo_failure_still_renders_text(self):
manager = _RenderableCelebrating()
def boom(*a, **k):
raise RuntimeError("disk gone")
manager._load_and_resize_logo = boom
celebration = _armed(manager, started_ago=5) # steady background
manager._draw_celebration_layout(celebration) # must not raise
assert manager.display_manager.image.convert("L").getbbox() is not None
manager.display_manager.update_display.assert_called_once()
class TestCelebrationEdges:
def test_should_celebrate_for_three_way_branch(self, celebrating):
g = game("g1", home="FAV", away="OPP")
favored = celebrating(favorites=["FAV"])
assert favored._should_celebrate_for(g, "home") is True # favorite
assert favored._should_celebrate_for(g, "away") is False # opponent
favored.celebrate_opponent_scores = True
assert favored._should_celebrate_for(g, "away") is True # opted in
unconfigured = celebrating(favorites=[])
assert unconfigured._should_celebrate_for(g, "away") is True # no favs
def test_active_celebration_boundary_is_strict(self, celebrating):
manager = celebrating(mode_config={"celebration_duration": 3})
manager.active_celebration = {"started_at": time.time() - 3.0}
# elapsed == duration -> strictly-less-than comparison says done.
assert manager.has_active_celebration() is False
manager.active_celebration = None
assert manager.has_active_celebration() is False
@pytest.mark.parametrize("value,expected", [
({"value": None}, None), # int(float(None)) TypeError -> caught
({"value": "abc"}, None),
({"other": 1}, 0), # neither key -> default 0
([3], None), # list -> TypeError -> caught
("-4", None), # regex fallback finds digits -> 4? No:
])
def test_score_to_int_edges(self, value, expected):
result = CelebrationMixin._score_to_int(value)
if value == "-4":
# int(float("-4")) parses directly: -4.
assert result == -4
else:
assert result == expected
def test_both_teams_scoring_prefers_away(self, celebrating):
manager = celebrating(favorites=[])
manager._check_for_score(game("g1", home_score=0, away_score=0))
manager._check_for_score(game("g1", home_score=7, away_score=3))
assert manager.active_celebration["scored_side"] == "away"
def test_away_not_celebratable_falls_through_to_home(self, celebrating):
manager = celebrating(favorites=["HOM"]) # away is the opponent
manager._check_for_score(game("g1", home_score=0, away_score=0))
manager._check_for_score(game("g1", home_score=7, away_score=3))
assert manager.active_celebration["scored_side"] == "home"
def test_coalesce_expired_celebration_fires_fresh(self, celebrating):
manager = celebrating(cls=_Coalescing,
mode_config={"celebration_duration": 1})
manager._check_for_score(game("g1"))
manager._check_for_score(game("g1", home_score=6))
first = manager.active_celebration
assert first is not None
first["started_at"] = time.time() - 2 # expired
manager._check_for_score(game("g1", home_score=7))
# A new celebration replaced the expired one (coalescing only
# suppresses while one is actively on screen).
assert manager.active_celebration is not first
assert manager.active_celebration["home_score"] == 7
def test_disabled_win_check_preserves_baseline(self, celebrating):
manager = celebrating(favorites=["HOM"])
manager._check_for_score(game("g1"))
assert "g1" in manager._score_baselines
manager.celebration_enabled = False
manager._check_for_win(game("g1", home_score=7))
# Early return BEFORE consuming the baseline: re-enabling later can
# still fire for this game.
assert "g1" in manager._score_baselines
def test_prune_drops_baselines_for_idless_live_games(self, celebrating):
manager = celebrating()
manager._score_baselines = {"g1": {"away": 0, "home": 0}}
manager.prune_score_baselines([{"no_id_here": True}])
# live ids collapse to {None}; g1 is not live -> dropped.
assert manager._score_baselines == {}
+293
View File
@@ -0,0 +1,293 @@
"""
Tests for src/startup_validator.py pins the StartupValidator contract.
Covers: required-key/config error reporting (errors never propagate out of
validate_all), the load_config/get_config accessor split, cache-directory
error-vs-warning downgrade behavior, plugin discovery/manifest checks with
reserved config keys skipped, idempotent validate_all (fresh error/warning
lists each run the pre-fix behavior duplicated messages), and the
exception classification precedence in raise_on_errors (config > cache >
plugin > fallback ConfigError).
"""
import copy
import os
from unittest.mock import MagicMock
import pytest
from src.exceptions import CacheError, ConfigError, PluginError
from src.startup_validator import StartupValidator
GOOD_CONFIG = {
'display': {'hardware': {'rows': 32, 'cols': 64}},
'timezone': 'UTC',
}
def make_config_manager(config):
"""Config manager whose load_config() and get_config() return `config`."""
mgr = MagicMock()
mgr.load_config.return_value = copy.deepcopy(config)
mgr.get_config.return_value = copy.deepcopy(config)
return mgr
@pytest.fixture
def good_cache(monkeypatch, tmp_path):
"""Patch CacheManager so cache validation sees an existing writable dir.
_validate_cache_directory does `from src.cache_manager import CacheManager`
at call time, so patching the attribute on the module is picked up.
"""
mock_cls = MagicMock()
mock_cls.return_value.get_cache_dir.return_value = str(tmp_path)
monkeypatch.setattr("src.cache_manager.CacheManager", mock_cls)
return tmp_path
class TestValidateConfig:
"""Configuration validation via load_config()."""
def test_happy_path(self, good_cache):
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
is_valid, errors, warnings = validator.validate_all()
assert is_valid is True
assert errors == []
assert warnings == []
def test_missing_required_keys(self, good_cache):
validator = StartupValidator(make_config_manager({}))
is_valid, errors, warnings = validator.validate_all()
assert is_valid is False
assert "Missing required configuration key: display" in errors
assert "Missing required configuration key: timezone" in errors
def test_config_error_does_not_propagate(self, good_cache):
mgr = make_config_manager(GOOD_CONFIG)
mgr.load_config.side_effect = ConfigError("bad json")
validator = StartupValidator(mgr)
is_valid, errors, warnings = validator.validate_all()
assert is_valid is False
config_errors = [e for e in errors if e.startswith("Configuration error:")]
assert len(config_errors) == 1
assert "bad json" in config_errors[0]
def test_unexpected_error_does_not_propagate(self, good_cache):
mgr = make_config_manager(GOOD_CONFIG)
mgr.load_config.side_effect = RuntimeError("kapow")
validator = StartupValidator(mgr)
is_valid, errors, warnings = validator.validate_all()
assert is_valid is False
unexpected = [e for e in errors
if e.startswith("Unexpected error validating configuration:")]
assert len(unexpected) == 1
assert "kapow" in unexpected[0]
def test_accessor_split_get_config_failure_is_warning_only(self, good_cache):
# _validate_config uses load_config(); _validate_display_config uses
# get_config(). A broken get_config must degrade to a warning, not
# crash or produce a config error.
mgr = make_config_manager(GOOD_CONFIG)
mgr.get_config.side_effect = RuntimeError("accessor broken")
validator = StartupValidator(mgr)
is_valid, errors, warnings = validator.validate_all()
assert is_valid is True
assert errors == []
assert any(w.startswith("Could not validate display configuration:")
for w in warnings)
assert mgr.load_config.called
assert mgr.get_config.called
class TestDisplayConfig:
"""Display hardware validation via get_config()."""
def test_missing_hardware_section_is_error(self, good_cache):
config = {'display': {'runtime': {'gpio_slowdown': 2}}, 'timezone': 'UTC'}
validator = StartupValidator(make_config_manager(config))
is_valid, errors, warnings = validator.validate_all()
assert is_valid is False
assert "Display hardware configuration is missing" in errors
def test_missing_rows_cols_are_warnings_not_errors(self, good_cache):
config = {'display': {'hardware': {'brightness': 90}}, 'timezone': 'UTC'}
validator = StartupValidator(make_config_manager(config))
is_valid, errors, warnings = validator.validate_all()
assert is_valid is True
assert errors == []
assert "Display hardware setting 'rows' not specified, using default" in warnings
assert "Display hardware setting 'cols' not specified, using default" in warnings
class TestCacheDirectory:
"""Cache directory validation error/warning split."""
def _patch_cache_dir(self, monkeypatch, cache_dir):
mock_cls = MagicMock()
mock_cls.return_value.get_cache_dir.return_value = cache_dir
monkeypatch.setattr("src.cache_manager.CacheManager", mock_cls)
def test_nonexistent_cache_dir_is_error(self, monkeypatch, tmp_path):
missing = str(tmp_path / "does_not_exist")
self._patch_cache_dir(monkeypatch, missing)
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
is_valid, errors, warnings = validator.validate_all()
assert is_valid is False
assert any("does not exist" in e and missing in e for e in errors)
def test_writable_cache_dir_no_errors(self, monkeypatch, tmp_path):
self._patch_cache_dir(monkeypatch, str(tmp_path))
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
is_valid, errors, warnings = validator.validate_all()
assert is_valid is True
assert not any('cache' in e.lower() for e in errors)
def test_unwritable_cache_dir_is_error(self, monkeypatch, tmp_path):
# Root (common in CI) can write anywhere, so chmod tricks don't
# work — force os.access to deny writes for the cache dir only.
cache_dir = str(tmp_path)
self._patch_cache_dir(monkeypatch, cache_dir)
real_access = os.access
def fake_access(path, mode):
if str(path) == cache_dir and mode == os.W_OK:
return False
return real_access(path, mode)
monkeypatch.setattr(os, "access", fake_access)
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
is_valid, errors, warnings = validator.validate_all()
assert is_valid is False
assert any("is not writable" in e for e in errors)
def test_none_cache_dir_is_warning_not_error(self, monkeypatch):
self._patch_cache_dir(monkeypatch, None)
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
is_valid, errors, warnings = validator.validate_all()
assert is_valid is True
assert errors == []
assert "Cache directory not available - caching will be disabled" in warnings
def test_cache_manager_constructor_failure_is_warning(self, monkeypatch):
mock_cls = MagicMock(side_effect=RuntimeError("no disk"))
monkeypatch.setattr("src.cache_manager.CacheManager", mock_cls)
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
is_valid, errors, warnings = validator.validate_all()
assert is_valid is True
assert errors == []
assert any(w.startswith("Could not validate cache directory:") for w in warnings)
class TestPlugins:
"""Plugin validation with a plugin manager present."""
def _config_with_plugins(self):
return {
'display': {'hardware': {'rows': 32, 'cols': 64}},
'schedule': {'enabled': True}, # reserved key that LOOKS enabled
'timezone': 'UTC',
'plugin_system': {},
'known': {'enabled': True},
'ghost': {'enabled': True},
}
def test_ghost_plugin_warns_and_reserved_keys_skipped(self, good_cache, tmp_path):
pm = MagicMock()
pm.discover_plugins.return_value = ['known']
known_dir = tmp_path / "known"
known_dir.mkdir()
(known_dir / "manifest.json").write_text("{}")
pm.get_plugin_directory.return_value = str(known_dir)
validator = StartupValidator(make_config_manager(self._config_with_plugins()), pm)
is_valid, errors, warnings = validator.validate_all()
assert is_valid is True
assert "Plugin 'ghost' is enabled but not found in plugins directory" in warnings
# Reserved sections are never treated as plugins, even when they
# contain an 'enabled' flag (schedule above).
for reserved in ('display', 'schedule', 'timezone', 'plugin_system'):
assert not any(f"'{reserved}'" in w for w in warnings)
def test_enabled_plugin_missing_manifest_is_error(self, good_cache, tmp_path):
pm = MagicMock()
pm.discover_plugins.return_value = ['known']
plugin_dir = tmp_path / "known"
plugin_dir.mkdir() # exists, but no manifest.json inside
pm.get_plugin_directory.return_value = str(plugin_dir)
config = dict(GOOD_CONFIG, known={'enabled': True})
validator = StartupValidator(make_config_manager(config), pm)
is_valid, errors, warnings = validator.validate_all()
assert is_valid is False
assert "Plugin 'known' manifest.json not found" in errors
def test_disabled_plugin_not_checked_for_manifest(self, good_cache, tmp_path):
pm = MagicMock()
pm.discover_plugins.return_value = ['known']
pm.get_plugin_directory.return_value = str(tmp_path / "nowhere")
config = dict(GOOD_CONFIG, known={'enabled': False})
validator = StartupValidator(make_config_manager(config), pm)
is_valid, errors, warnings = validator.validate_all()
assert is_valid is True
assert errors == []
class TestIdempotence:
"""validate_all() resets error/warning state each run (the fixed bug)."""
def test_repeated_runs_do_not_accumulate(self, good_cache):
validator = StartupValidator(make_config_manager({}))
first = validator.validate_all()
second = validator.validate_all()
assert first == second
assert len(second[1]) == len(first[1])
assert len(second[2]) == len(first[2])
class TestRaiseOnErrors:
"""Exception classification and precedence in raise_on_errors()."""
def _validator(self, errors):
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
validator.errors = list(errors)
return validator
def test_no_errors_returns_none(self):
assert self._validator([]).raise_on_errors() is None
def test_config_error(self):
msg = "Missing required configuration key: display"
with pytest.raises(ConfigError) as excinfo:
self._validator([msg]).raise_on_errors()
assert excinfo.value.message == "Configuration validation failed"
assert msg in excinfo.value.context['errors']
def test_cache_error(self):
msg = "Cache directory does not exist: /nope"
with pytest.raises(CacheError) as excinfo:
self._validator([msg]).raise_on_errors()
assert msg in excinfo.value.context['errors']
def test_plugin_error(self):
msg = "Plugin 'known' manifest.json not found"
with pytest.raises(PluginError) as excinfo:
self._validator([msg]).raise_on_errors()
assert msg in excinfo.value.context['errors']
def test_unclassified_error_falls_back_to_config_error(self):
msg = "Something entirely else went wrong"
with pytest.raises(ConfigError) as excinfo:
self._validator([msg]).raise_on_errors()
assert excinfo.value.message == "Startup validation failed"
assert msg in excinfo.value.context['errors']
def test_precedence_config_beats_cache(self):
# A message matching both 'config' and 'cache' substrings raises
# ConfigError because config classification is checked first.
msg = "config problem touching the cache layer"
with pytest.raises(ConfigError) as excinfo:
self._validator([msg]).raise_on_errors()
assert excinfo.value.message == "Configuration validation failed"
assert msg in excinfo.value.context['errors']
+969
View File
@@ -0,0 +1,969 @@
"""
Tests for src/common/sync_manager.py the UDP leader/follower protocol
that synchronizes scrolling content across two LED matrix displays.
This module had zero coverage: it only ever appeared in the suite as a
MagicMock() stand-in (test_vegas_continuous_refresh.py,
test_display_controller_vegas_tick.py), so none of its real framing,
handshake, or socket logic was exercised.
Most tests build the manager via object.__new__() + manual attribute
assignment (the test_display_controller_vegas_tick.py bare-stub pattern)
so no real sockets open and no background threads start. Receive loops are
driven synchronously by once_then_stop(): the mocked socket call returns
one crafted packet, then flips _running False and raises socket.timeout,
so `while self._running:` exits after exactly one real iteration.
Regression coverage for three fixed bugs:
- Both recv loops' generic `except Exception` retried with no delay, so a
socket stuck raising a non-timeout error spun the thread at 100% CPU.
- _follower_recv_loop dispatched on `data[:8] == _RAW_MAGIC or
len(data) > 512`, which routed any control message over 512 bytes into
the image decoder (dropping it) and any raw frame under 512 bytes into
the JSON parser.
- _oversized_frame_warned was read via getattr(self, ..., False) instead of
being initialized in __init__.
"""
import io
import json
import socket
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from PIL import Image
from src.common import sync_manager
from src.common.sync_manager import (
DisplaySyncManager,
FollowerState,
LeaderState,
SyncRole,
)
@pytest.fixture(autouse=True)
def _isolated_status_file(tmp_path, monkeypatch):
# STATUS_FILE is a module-level fixed path under tempfile.gettempdir() —
# genuinely shared state between tests and even between processes.
monkeypatch.setattr(
sync_manager, "STATUS_FILE", str(tmp_path / "led_matrix_sync_status.json"))
def make_manager(role=SyncRole.STANDALONE, hw_config=None):
"""Bare stub bypassing __init__'s socket/thread setup."""
mgr = object.__new__(DisplaySyncManager)
mgr.role = role
mgr.logger = MagicMock()
mgr.port = sync_manager.SYNC_PORT
mgr._hw_config = hw_config or {"rows": 32, "cols": 64, "chain_length": 1}
mgr._leader_state = LeaderState.NO_PEER
mgr._peer_ip = None
mgr._peer_compatible = False
mgr._peer_chain = 0
mgr._last_heartbeat_time = 0.0
mgr._leader_width = 0
mgr._oversized_frame_warned = False
mgr._follower_state = FollowerState.STANDALONE
mgr._latest_frame = None
mgr._latest_scroll_x = None
mgr._last_leader_frame_time = 0.0
mgr._frame_lock = threading.Lock()
mgr._leader_ip = None
mgr._on_new_cycle = None
mgr._on_scroll_image = None
mgr._pending_scroll_image = None
mgr._scroll_image_lock = threading.Lock()
mgr._img_server_sock = None
mgr._on_follower_connected = None
mgr._error_message = None
mgr._running = False
mgr._recv_sock = None
mgr._send_sock = None
return mgr
def once_then_stop(mgr, value):
"""side_effect returning `value` once, then stopping the enclosing loop."""
state = {"served": False}
def _side_effect(*args, **kwargs):
if not state["served"]:
state["served"] = True
return value
mgr._running = False
raise socket.timeout()
return _side_effect
def raise_n_then_stop(mgr, exc, count):
"""side_effect raising `exc` `count` times, then stopping the loop."""
state = {"n": 0}
def _side_effect(*args, **kwargs):
state["n"] += 1
if state["n"] <= count:
raise exc
mgr._running = False
raise socket.timeout()
return _side_effect
def fake_clock(monkeypatch, *, time_fn=None, sleep_fn=None):
"""Swap sync_manager's own `time` reference for a private stand-in.
sync_manager.time IS the stdlib module, so patching attributes on it
would freeze the clock and no-op sleep for the whole process
including the daemon threads earlier tests left running, which is a
hard-to-trace source of cross-test flakiness. Rebinding the module's
reference keeps the patch scoped to the code under test. Anything not
overridden falls through to the real functions.
"""
monkeypatch.setattr(sync_manager, "time", SimpleNamespace(
time=time_fn or time.time,
sleep=sleep_fn or time.sleep,
))
def run_watchdog_once(monkeypatch, mgr, watchdog, now):
"""Run exactly one watchdog iteration at a frozen wall-clock time."""
fake_clock(monkeypatch,
time_fn=lambda: now,
sleep_fn=lambda _: setattr(mgr, "_running", False))
mgr._running = True
watchdog()
class FakeConn:
"""Minimal TCP connection stand-in whose recv() drains a byte buffer."""
def __init__(self, payload: bytes):
self._buf = payload
self.closed = False
def settimeout(self, _):
pass
def recv(self, n):
chunk, self._buf = self._buf[:n], self._buf[n:]
return chunk
def close(self):
self.closed = True
def png_bytes(size=(10, 10), color=(1, 2, 3)) -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, format="PNG")
return buf.getvalue()
def raw_frame_packet(width, height, color=(10, 20, 30)) -> bytes:
arr = np.asarray(Image.new("RGB", (width, height), color), dtype=np.uint8)
return _magic_header(width, height) + arr.tobytes()
def _magic_header(width, height) -> bytes:
return sync_manager._RAW_MAGIC + sync_manager._RAW_HEADER.pack(width, height)
def length_prefixed(payload: bytes) -> bytes:
return len(payload).to_bytes(4, "big") + payload
class TestRoleParsing:
def test_leader_role(self, monkeypatch):
monkeypatch.setattr(DisplaySyncManager, "_start_leader", lambda self: None)
assert DisplaySyncManager("leader", {}, {}, MagicMock()).role is SyncRole.LEADER
def test_follower_role(self, monkeypatch):
monkeypatch.setattr(DisplaySyncManager, "_start_follower", lambda self: None)
assert DisplaySyncManager("follower", {}, {}, MagicMock()).role is SyncRole.FOLLOWER
def test_standalone_starts_nothing(self):
mgr = DisplaySyncManager("standalone", {}, {}, MagicMock())
assert mgr.role is SyncRole.STANDALONE
assert mgr._running is False
assert mgr._recv_sock is None
def test_invalid_role_warns_and_falls_back(self):
logger = MagicMock()
assert DisplaySyncManager("bogus", {}, {}, logger).role is SyncRole.STANDALONE
assert logger.warning.called
def test_role_matching_is_case_sensitive(self):
# Pinned: SyncRole's values are lowercase, so "LEADER" is not
# normalized — it is simply invalid and falls back to standalone.
logger = MagicMock()
assert DisplaySyncManager("LEADER", {}, {}, logger).role is SyncRole.STANDALONE
assert logger.warning.called
def test_port_defaults_to_module_constant(self):
assert DisplaySyncManager("standalone", {}, {}, MagicMock()).port == sync_manager.SYNC_PORT
def test_port_read_from_config(self):
assert DisplaySyncManager("standalone", {"port": 9999}, {}, MagicMock()).port == 9999
def test_oversized_frame_warned_initialized_in_init(self, monkeypatch):
# Regression: this attribute was only ever created on first use via
# getattr(self, '_oversized_frame_warned', False).
monkeypatch.setattr(DisplaySyncManager, "_start_leader", lambda self: None)
mgr = DisplaySyncManager("leader", {}, {}, MagicMock())
assert mgr._oversized_frame_warned is False
class TestHandleHello:
def test_matching_panels_connect(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._send_sock = MagicMock()
mgr._handle_hello({"t": "hello", "rows": 32, "cols": 64, "chain": 3}, "10.0.0.5")
assert mgr._leader_state is LeaderState.CONNECTED
assert mgr._peer_ip == "10.0.0.5"
assert mgr._peer_compatible is True
assert mgr._peer_chain == 3
assert mgr._error_message is None
def test_ack_reports_compatibility(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._send_sock = MagicMock()
mgr._leader_width = 128
mgr._handle_hello({"t": "hello", "rows": 32, "cols": 64, "chain": 1}, "10.0.0.5")
payload, dest = mgr._send_sock.sendto.call_args[0]
ack = json.loads(payload.decode("utf-8"))
assert ack["compatible"] is True
assert ack["leader_width"] == 128
assert dest == ("10.0.0.5", mgr.port)
def test_mismatched_panels_are_incompatible(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._send_sock = MagicMock()
mgr._handle_hello({"t": "hello", "rows": 16, "cols": 32, "chain": 1}, "10.0.0.5")
assert mgr._leader_state is LeaderState.INCOMPATIBLE
assert "Incompatible panels" in mgr._error_message
ack = json.loads(mgr._send_sock.sendto.call_args[0][0].decode("utf-8"))
assert ack["compatible"] is False
assert ack["error"] == mgr._error_message
def test_chain_length_may_differ(self):
# Documented rule: rows/cols must match, chain_length need not.
mgr = make_manager(role=SyncRole.LEADER, hw_config={"rows": 32, "cols": 64, "chain_length": 1})
mgr._send_sock = MagicMock()
mgr._handle_hello({"t": "hello", "rows": 32, "cols": 64, "chain": 4}, "10.0.0.5")
assert mgr._leader_state is LeaderState.CONNECTED
def test_connect_callback_fires_only_on_first_transition(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._send_sock = MagicMock()
fired = threading.Event()
calls = []
mgr._on_follower_connected = lambda: (calls.append(1), fired.set())
hello = {"t": "hello", "rows": 32, "cols": 64, "chain": 1}
mgr._handle_hello(hello, "10.0.0.5")
assert fired.wait(timeout=1)
assert len(calls) == 1
fired.clear()
mgr._handle_hello(hello, "10.0.0.5") # already CONNECTED
assert not fired.wait(timeout=0.2)
assert len(calls) == 1
def test_ack_send_failure_is_swallowed(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._send_sock = MagicMock()
mgr._send_sock.sendto.side_effect = OSError("network unreachable")
mgr._handle_hello({"t": "hello", "rows": 32, "cols": 64, "chain": 1}, "10.0.0.5")
assert mgr._leader_state is LeaderState.CONNECTED # state still updated
assert mgr.logger.debug.called
class TestWatchdogs:
def test_leader_drops_peer_after_heartbeat_timeout(self, monkeypatch):
mgr = make_manager(role=SyncRole.LEADER)
mgr._leader_state = LeaderState.CONNECTED
mgr._peer_ip = "10.0.0.1"
mgr._peer_compatible = True
mgr._last_heartbeat_time = 0.0
run_watchdog_once(monkeypatch, mgr, mgr._leader_watchdog,
now=sync_manager.PEER_TIMEOUT + 1)
assert mgr._leader_state is LeaderState.NO_PEER
assert mgr._peer_ip is None
assert mgr._peer_compatible is False
def test_leader_keeps_peer_within_timeout(self, monkeypatch):
mgr = make_manager(role=SyncRole.LEADER)
mgr._leader_state = LeaderState.CONNECTED
mgr._peer_ip = "10.0.0.1"
mgr._last_heartbeat_time = 100.0
run_watchdog_once(monkeypatch, mgr, mgr._leader_watchdog, now=101.0)
assert mgr._leader_state is LeaderState.CONNECTED
assert mgr._peer_ip == "10.0.0.1"
def test_leader_watchdog_ignores_disconnected_state(self, monkeypatch):
mgr = make_manager(role=SyncRole.LEADER)
mgr._leader_state = LeaderState.INCOMPATIBLE
mgr._last_heartbeat_time = 0.0
run_watchdog_once(monkeypatch, mgr, mgr._leader_watchdog, now=10_000)
assert mgr._leader_state is LeaderState.INCOMPATIBLE
def test_follower_returns_to_standalone_after_frame_timeout(self, monkeypatch):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._follower_state = FollowerState.FOLLOWER
mgr._last_leader_frame_time = 0.0
mgr._latest_frame = Image.new("RGB", (2, 2))
run_watchdog_once(monkeypatch, mgr, mgr._follower_watchdog,
now=sync_manager.LEADER_TIMEOUT + 1)
assert mgr._follower_state is FollowerState.STANDALONE
assert mgr.get_latest_frame() is None
def test_follower_keeps_frames_within_timeout(self, monkeypatch):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._follower_state = FollowerState.FOLLOWER
mgr._last_leader_frame_time = 100.0
mgr._latest_frame = Image.new("RGB", (2, 2))
run_watchdog_once(monkeypatch, mgr, mgr._follower_watchdog, now=101.0)
assert mgr._follower_state is FollowerState.FOLLOWER
assert mgr.get_latest_frame() is not None
class TestLeaderRecvLoop:
def _drive(self, mgr, payload, sender="10.0.0.8"):
mgr._recv_sock = MagicMock()
mgr._recv_sock.recvfrom.side_effect = once_then_stop(mgr, (payload, (sender, 1)))
mgr._running = True
mgr._leader_recv_loop()
def test_hello_is_dispatched(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._send_sock = MagicMock()
self._drive(mgr, json.dumps(
{"t": "hello", "rows": 32, "cols": 64, "chain": 1}).encode())
assert mgr._leader_state is LeaderState.CONNECTED
assert mgr._peer_ip == "10.0.0.8"
def test_heartbeat_from_known_peer_refreshes_timer(self, monkeypatch):
mgr = make_manager(role=SyncRole.LEADER)
mgr._peer_ip = "10.0.0.8"
fake_clock(monkeypatch, time_fn=lambda: 12345.0)
self._drive(mgr, json.dumps({"t": "hb"}).encode())
assert mgr._last_heartbeat_time == 12345.0
def test_heartbeat_from_stranger_is_ignored(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._peer_ip = "10.0.0.8"
mgr._last_heartbeat_time = 5.0
self._drive(mgr, json.dumps({"t": "hb"}).encode(), sender="10.0.0.99")
assert mgr._last_heartbeat_time == 5.0
def test_unknown_message_type_ignored(self):
mgr = make_manager(role=SyncRole.LEADER)
self._drive(mgr, json.dumps({"t": "who-knows"}).encode())
assert mgr._leader_state is LeaderState.NO_PEER
def test_malformed_json_is_swallowed(self):
mgr = make_manager(role=SyncRole.LEADER)
self._drive(mgr, b"{not json")
assert mgr._leader_state is LeaderState.NO_PEER
def test_undecodable_bytes_are_swallowed(self):
mgr = make_manager(role=SyncRole.LEADER)
self._drive(mgr, b"\xff\xfe\x00bad")
assert mgr._leader_state is LeaderState.NO_PEER
def test_backs_off_between_repeated_errors(self, monkeypatch):
# Regression: without a sleep this loop spun at 100% CPU whenever
# the socket raised a non-timeout error on every call.
mgr = make_manager(role=SyncRole.LEADER)
mgr._recv_sock = MagicMock()
mgr._recv_sock.recvfrom.side_effect = raise_n_then_stop(mgr, OSError("boom"), 3)
sleeps = MagicMock()
fake_clock(monkeypatch, sleep_fn=sleeps)
mgr._running = True
mgr._leader_recv_loop()
assert sleeps.call_count == 3
sleeps.assert_called_with(0.1)
class TestFollowerRecvLoop:
def _drive(self, mgr, payload, sender="10.0.0.2"):
mgr._recv_sock = MagicMock()
mgr._recv_sock.recvfrom.side_effect = once_then_stop(mgr, (payload, (sender, 1)))
mgr._running = True
mgr._follower_recv_loop()
def test_small_raw_frame_is_decoded(self):
# Regression: a raw frame under the old 512-byte threshold was sent
# to the JSON parser and dropped.
mgr = make_manager(role=SyncRole.FOLLOWER)
packet = raw_frame_packet(4, 3)
assert len(packet) <= 512
self._drive(mgr, packet)
frame = mgr.get_latest_frame()
assert frame is not None and frame.size == (4, 3)
assert mgr._follower_state is FollowerState.FOLLOWER
def test_large_raw_frame_is_decoded(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
packet = raw_frame_packet(64, 32)
assert len(packet) > 512
self._drive(mgr, packet)
assert mgr.get_latest_frame().size == (64, 32)
def test_large_control_message_is_not_routed_to_image_decode(self):
# Regression: the old `len(data) > 512` branch treated any large
# control message as frame data and silently discarded it.
mgr = make_manager(role=SyncRole.FOLLOWER)
long_error = "x" * 600
payload = json.dumps(
{"t": "hello_ack", "compatible": False, "error": long_error}).encode()
assert len(payload) > 512
self._drive(mgr, payload, sender="10.0.0.9")
assert mgr._leader_ip == "10.0.0.9"
assert mgr._peer_compatible is False
assert mgr._error_message == long_error
assert mgr.get_latest_frame() is None
assert mgr.logger.error.called
def test_legacy_png_frame_without_magic_is_decoded(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
self._drive(mgr, png_bytes(size=(5, 5)))
frame = mgr.get_latest_frame()
assert frame is not None and frame.size == (5, 5)
assert mgr._follower_state is FollowerState.FOLLOWER
def test_truncated_raw_frame_is_swallowed(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
self._drive(mgr, _magic_header(64, 32) + b"\x00" * 10) # far too short
assert mgr.get_latest_frame() is None
assert mgr.logger.debug.called
def test_garbage_payload_is_swallowed(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
self._drive(mgr, b"neither json nor a png, just bytes 1234567890")
assert mgr.get_latest_frame() is None
def test_hello_ack_updates_peer_state(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
self._drive(mgr, json.dumps(
{"t": "hello_ack", "compatible": True, "error": None}).encode(),
sender="10.0.0.6")
assert mgr._leader_ip == "10.0.0.6"
assert mgr._peer_compatible is True
assert mgr.logger.error.called is False
def test_scroll_x_switches_to_follower_and_builds_cycle(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
calls = []
mgr._on_new_cycle = lambda: calls.append(1)
self._drive(mgr, json.dumps({"t": "sx", "x": 12.34}).encode())
assert mgr._follower_state is FollowerState.FOLLOWER
assert mgr.get_latest_scroll_x() == 12.34
assert calls == [1]
def test_scroll_x_while_already_following_does_not_rebuild(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._follower_state = FollowerState.FOLLOWER
calls = []
mgr._on_new_cycle = lambda: calls.append(1)
self._drive(mgr, json.dumps({"t": "sx", "x": 5.0}).encode())
assert mgr.get_latest_scroll_x() == 5.0
assert calls == []
def test_new_cycle_message_triggers_callback(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._follower_state = FollowerState.FOLLOWER
calls = []
mgr._on_new_cycle = lambda: calls.append(1)
self._drive(mgr, json.dumps({"t": "nc"}).encode())
assert calls == [1]
def test_non_object_json_does_not_reach_the_outer_handler(self):
# A bare JSON scalar parses, then msg.get() raises AttributeError.
# That has to be caught here so the payload still gets its shot at
# the legacy-PNG fallback; escaping to the outer handler would also
# charge one malformed packet the 0.1s error backoff.
mgr = make_manager(role=SyncRole.FOLLOWER)
sleeps = MagicMock()
with patch.object(sync_manager, "time",
SimpleNamespace(time=time.time, sleep=sleeps)):
self._drive(mgr, b"12345")
assert mgr.get_latest_frame() is None
sleeps.assert_not_called()
def test_non_numeric_scroll_x_does_not_reach_the_outer_handler(self):
# float("a") raises ValueError; {"x": null} raises TypeError.
for payload in ({"t": "sx", "x": "a"}, {"t": "sx", "x": None}):
mgr = make_manager(role=SyncRole.FOLLOWER)
sleeps = MagicMock()
with patch.object(sync_manager, "time",
SimpleNamespace(time=time.time, sleep=sleeps)):
self._drive(mgr, json.dumps(payload).encode())
assert mgr.get_latest_scroll_x() is None
sleeps.assert_not_called()
def test_callback_failure_is_not_mistaken_for_a_malformed_packet(self, monkeypatch):
# A payload that parses is a control message, full stop. If the
# callback it triggers raises one of the types the field guard
# catches, that fault belongs to the callback: it must not send
# the packet to the image decoder, which would report it as a
# decode error and bury the real cause. The loop still survives
# it — the outer handler catches it like any other fault.
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._follower_state = FollowerState.FOLLOWER
def boom():
raise ValueError("callback is broken")
mgr._on_new_cycle = boom
fake_clock(monkeypatch, sleep_fn=MagicMock())
self._drive(mgr, json.dumps({"t": "nc"}).encode())
logged = " | ".join(str(c) for c in mgr.logger.debug.call_args_list)
assert "callback is broken" in logged
assert "frame decode error" not in logged
assert "malformed control message" not in logged
def test_oversized_legacy_frame_is_rejected_before_decode(self, monkeypatch):
# The UDP path is reachable by any host on the LAN, so it caps
# dimensions before load() just as the TCP image server does.
mgr = make_manager(role=SyncRole.FOLLOWER)
class Huge:
width, height = 10, sync_manager._MAX_FRAME_H + 1
def load(self):
raise AssertionError("load() must not run past the cap")
# Rebind the module's reference rather than mutating PIL.Image
# itself, which would hand Huge() to every caller in the process
# — including daemon threads earlier tests left running. Same
# reasoning as fake_clock above. The other names the receive loop
# reads off this reference pass through to the real module.
monkeypatch.setattr(sync_manager, "Image", SimpleNamespace(
open=lambda *a, **kw: Huge(),
frombuffer=Image.frombuffer,
DecompressionBombError=Image.DecompressionBombError,
))
self._drive(mgr, b"\x89PNG not really but not JSON either")
assert mgr.get_latest_frame() is None
def test_scroll_x_missing_key_is_swallowed(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
self._drive(mgr, json.dumps({"t": "sx"}).encode()) # no "x"
assert mgr.get_latest_scroll_x() is None
def test_backs_off_between_repeated_errors(self, monkeypatch):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._recv_sock = MagicMock()
mgr._recv_sock.recvfrom.side_effect = raise_n_then_stop(mgr, OSError("boom"), 3)
sleeps = MagicMock()
fake_clock(monkeypatch, sleep_fn=sleeps)
mgr._running = True
mgr._follower_recv_loop()
assert sleeps.call_count == 3
sleeps.assert_called_with(0.1)
class TestSendFrame:
def _connected_leader(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._leader_state = LeaderState.CONNECTED
mgr._peer_ip = "10.0.0.1"
mgr._send_sock = MagicMock()
return mgr
def test_frame_sent_with_magic_header(self):
mgr = self._connected_leader()
mgr.send_frame(Image.new("RGB", (8, 8)))
packet = mgr._send_sock.sendto.call_args[0][0]
assert packet[:8] == sync_manager._RAW_MAGIC
assert sync_manager._RAW_HEADER.unpack(packet[8:12]) == (8, 8)
def test_oversized_frame_warns_once_and_is_dropped(self):
mgr = self._connected_leader()
big = Image.new("RGB", (300, 300)) # 270000 bytes > 65000 UDP cap
mgr.send_frame(big)
assert mgr._oversized_frame_warned is True
assert mgr.logger.warning.call_count == 1
assert not mgr._send_sock.sendto.called
mgr.send_frame(big)
assert mgr.logger.warning.call_count == 1 # still warned only once
def test_not_sent_when_no_peer(self):
mgr = self._connected_leader()
mgr._leader_state = LeaderState.NO_PEER
mgr.send_frame(Image.new("RGB", (8, 8)))
assert not mgr._send_sock.sendto.called
def test_follower_never_sends(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._send_sock = MagicMock()
mgr.send_frame(Image.new("RGB", (8, 8)))
assert not mgr._send_sock.sendto.called
def test_send_error_is_swallowed(self):
mgr = self._connected_leader()
mgr._send_sock.sendto.side_effect = OSError("no route")
mgr.send_frame(Image.new("RGB", (8, 8))) # must not raise
assert mgr.logger.debug.called
class TestSendControlMessages:
def _connected_leader(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._leader_state = LeaderState.CONNECTED
mgr._peer_ip = "10.0.0.1"
mgr._send_sock = MagicMock()
return mgr
def test_send_scroll_x_rounds_to_two_places(self):
mgr = self._connected_leader()
mgr.send_scroll_x(3.14159)
msg = json.loads(mgr._send_sock.sendto.call_args[0][0].decode())
assert msg == {"t": "sx", "x": 3.14}
def test_send_new_cycle(self):
mgr = self._connected_leader()
mgr.send_new_cycle()
msg = json.loads(mgr._send_sock.sendto.call_args[0][0].decode())
assert msg == {"t": "nc"}
def test_control_messages_noop_when_disconnected(self):
mgr = self._connected_leader()
mgr._leader_state = LeaderState.NO_PEER
mgr.send_scroll_x(1.0)
mgr.send_new_cycle()
assert not mgr._send_sock.sendto.called
def test_set_leader_width(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr.set_leader_width(256)
assert mgr._leader_width == 256
class TestImageServerLoop:
def _drive(self, mgr, conn):
mgr._img_server_sock = MagicMock()
mgr._img_server_sock.accept.side_effect = once_then_stop(
mgr, (conn, ("10.0.0.1", 1)))
mgr._running = True
mgr._image_server_loop()
def test_rejects_non_positive_length(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._on_scroll_image = MagicMock()
self._drive(mgr, FakeConn((0).to_bytes(4, "big")))
assert mgr.logger.warning.called
mgr._on_scroll_image.assert_not_called()
def test_rejects_oversized_length(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._on_scroll_image = MagicMock()
self._drive(mgr, FakeConn((11 * 1024 * 1024).to_bytes(4, "big")))
assert mgr.logger.warning.called
mgr._on_scroll_image.assert_not_called()
def test_rejects_oversized_dimensions(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._on_scroll_image = MagicMock()
self._drive(mgr, FakeConn(length_prefixed(png_bytes(size=(300, 300)))))
assert mgr.logger.warning.called
mgr._on_scroll_image.assert_not_called()
def test_rejects_decompression_bomb(self, monkeypatch):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._on_scroll_image = MagicMock()
class BombImage:
width = height = 10
def load(self):
raise Image.DecompressionBombError("too many pixels")
monkeypatch.setattr(sync_manager.Image, "open", lambda *a, **kw: BombImage())
self._drive(mgr, FakeConn(length_prefixed(png_bytes())))
assert mgr.logger.warning.called
mgr._on_scroll_image.assert_not_called()
def test_valid_image_invokes_callback(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
received = []
mgr._on_scroll_image = received.append
self._drive(mgr, FakeConn(length_prefixed(png_bytes(size=(10, 10)))))
assert len(received) == 1
assert received[0].size == (10, 10)
def test_image_cached_when_callback_not_yet_registered(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._on_scroll_image = None
self._drive(mgr, FakeConn(length_prefixed(png_bytes(size=(6, 6)))))
assert mgr._pending_scroll_image is not None
assert mgr._pending_scroll_image.size == (6, 6)
def test_short_header_is_skipped(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._on_scroll_image = MagicMock()
self._drive(mgr, FakeConn(b"\x00\x01")) # under the 4-byte prefix
mgr._on_scroll_image.assert_not_called()
def test_connection_always_closed(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
conn = FakeConn(length_prefixed(png_bytes()))
self._drive(mgr, conn)
assert conn.closed is True
class TestScrollImageCallback:
def test_pending_image_delivered_on_late_registration(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
img = Image.new("RGB", (3, 3))
mgr._pending_scroll_image = img
received = []
mgr.set_on_scroll_image(received.append)
assert received == [img]
assert mgr._pending_scroll_image is None
def test_no_pending_image_means_no_immediate_call(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
received = []
mgr.set_on_scroll_image(received.append)
assert received == []
class TestFollowerConnectedCallback:
def test_fires_immediately_when_already_connected(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._leader_state = LeaderState.CONNECTED
fired = threading.Event()
mgr.set_on_follower_connected(fired.set)
assert fired.wait(timeout=1)
def test_does_not_fire_when_no_peer(self):
mgr = make_manager(role=SyncRole.LEADER)
fired = threading.Event()
mgr.set_on_follower_connected(fired.set)
assert not fired.wait(timeout=0.2)
class TestSendScrollImage:
def test_noop_when_not_connected(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._leader_state = LeaderState.NO_PEER
with patch.object(sync_manager.socket, "socket") as sock:
mgr.send_scroll_image(Image.new("RGB", (4, 4)))
sock.assert_not_called()
def test_noop_for_follower_role(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
with patch.object(sync_manager.socket, "socket") as sock:
mgr.send_scroll_image(Image.new("RGB", (4, 4)))
sock.assert_not_called()
def test_sends_length_prefixed_png(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._leader_state = LeaderState.CONNECTED
mgr._peer_ip = "10.0.0.1"
fake_sock = MagicMock()
fake_sock.__enter__ = lambda s: s
fake_sock.__exit__ = lambda s, *a: False
with patch.object(sync_manager.socket, "socket", return_value=fake_sock):
mgr.send_scroll_image(Image.new("RGB", (4, 4)))
payload = fake_sock.sendall.call_args[0][0]
assert int.from_bytes(payload[:4], "big") == len(payload) - 4
assert payload[4:8] == b"\x89PNG"
def test_connection_error_is_swallowed(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._leader_state = LeaderState.CONNECTED
mgr._peer_ip = "10.0.0.1"
with patch.object(sync_manager.socket, "socket", side_effect=OSError("refused")):
mgr.send_scroll_image(Image.new("RGB", (4, 4))) # must not raise
assert mgr.logger.debug.called
class TestGetStatus:
def test_standalone_shape(self):
status = make_manager(role=SyncRole.STANDALONE).get_status()
assert status["role"] == "standalone"
assert status["state"] == "standalone"
assert status["local_rows"] == 32 and status["local_cols"] == 64
def test_leader_shape(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._leader_state = LeaderState.CONNECTED
mgr._peer_ip = "10.0.0.1"
mgr._peer_compatible = True
mgr._peer_chain = 2
mgr._leader_width = 128
status = mgr.get_status()
assert status["role"] == "leader"
assert status["state"] == "connected"
assert status["peer_ip"] == "10.0.0.1"
assert status["peer_chain"] == 2
assert status["leader_width"] == 128
def test_follower_shape(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
mgr._follower_state = FollowerState.FOLLOWER
mgr._leader_ip = "10.0.0.2"
status = mgr.get_status()
assert status["role"] == "follower"
assert status["state"] == "follower"
assert status["leader_ip"] == "10.0.0.2"
assert "peer_chain" not in status
def test_is_follower_active(self):
mgr = make_manager(role=SyncRole.FOLLOWER)
assert mgr.is_follower_active() is False
mgr._follower_state = FollowerState.FOLLOWER
assert mgr.is_follower_active() is True
def test_leader_is_never_follower_active(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._follower_state = FollowerState.FOLLOWER
assert mgr.is_follower_active() is False
class TestWriteStatusFile:
def test_writes_status_and_cleans_up_temp(self):
mgr = make_manager(role=SyncRole.STANDALONE)
mgr.write_status_file()
data = json.loads(Path(sync_manager.STATUS_FILE).read_text())
assert data["role"] == "standalone"
assert "ts" in data
assert not Path(sync_manager.STATUS_FILE + ".tmp").exists()
def test_write_failure_is_swallowed(self, monkeypatch):
mgr = make_manager(role=SyncRole.STANDALONE)
monkeypatch.setattr("builtins.open", MagicMock(side_effect=OSError("disk full")))
mgr.write_status_file() # must not raise
assert mgr.logger.debug.called
class TestStop:
def _stub_with_sockets(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._recv_sock = MagicMock()
mgr._send_sock = MagicMock()
mgr._img_server_sock = MagicMock()
return mgr
def test_closes_every_socket(self):
mgr = self._stub_with_sockets()
mgr.stop()
assert mgr._running is False
mgr._recv_sock.close.assert_called_once()
mgr._send_sock.close.assert_called_once()
mgr._img_server_sock.close.assert_called_once()
def test_is_idempotent(self):
mgr = self._stub_with_sockets()
mgr.stop()
mgr.stop() # must not raise
def test_close_failure_is_swallowed(self):
mgr = make_manager(role=SyncRole.LEADER)
mgr._recv_sock = MagicMock()
mgr._recv_sock.close.side_effect = OSError("already closed")
mgr.stop() # must not raise
assert mgr.logger.debug.called
def test_handles_unset_sockets(self):
make_manager(role=SyncRole.STANDALONE).stop() # all sockets None
def _broadcast_available(port):
"""True when a UDP broadcast can be sent at all in this environment.
The handshake below depends on broadcast: the follower announces
itself to ("<broadcast>", port), and sync_manager swallows any sendto
error. Without this probe, a sandbox or CI network that refuses
broadcast would make the test wait out its whole deadline and then
fail for a reason that has nothing to do with the code.
Sending is enough to detect the case that actually occurs a
refusing environment raises here. Confirming *delivery* would mean
binding INADDR_ANY to receive, which is a listening socket this suite
has no reason to open; a network that accepts the send and silently
drops it still reaches the assertion, exactly as before.
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.sendto(b"probe", ("<broadcast>", port))
return True
except OSError:
return False
finally:
sock.close()
class TestRealSocketHandshake:
def test_leader_and_follower_negotiate_over_real_sockets(self, monkeypatch):
# One end-to-end check that the wire format actually round-trips:
# every other test drives the loops with mocked sockets.
#
# Not loopback-only, despite the free-port probe below: the manager
# binds UDP and TCP on all interfaces and the follower announces by
# broadcast. That is the behaviour under test, so the environment
# has to support it.
monkeypatch.setattr(sync_manager, "HELLO_INTERVAL", 0.02)
monkeypatch.setattr(sync_manager, "HEARTBEAT_INTERVAL", 0.02)
hw = {"rows": 32, "cols": 64, "chain_length": 1}
leader = follower = None
# The free-port probe is inherently racy — the port can be taken
# between release and rebind — so retry rather than fail on it.
for _attempt in range(5):
# Probed on loopback: this only needs a port number, and the
# manager's own bind is what has to succeed. If the port turns
# out to be taken on another interface, the retry below covers
# it — same as for the race.
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
probe.close()
if not _broadcast_available(port):
pytest.skip("environment refuses UDP broadcast")
try:
leader = DisplaySyncManager("leader", {"port": port}, hw, MagicMock())
follower = DisplaySyncManager("follower", {"port": port}, hw, MagicMock())
break
except OSError:
# Port taken between probe and bind, or the TCP image
# server could not bind port+1. Tear down whichever end
# came up before retrying with a fresh port.
for mgr in (leader, follower):
if mgr is not None:
mgr.stop()
leader = follower = None
else:
pytest.skip("could not obtain a free port pair for the handshake")
try:
deadline = time.time() + 5.0
while time.time() < deadline:
if (leader._leader_state is LeaderState.CONNECTED
and follower._peer_compatible):
break
time.sleep(0.02)
assert leader._leader_state is LeaderState.CONNECTED
assert follower._peer_compatible is True
assert follower._leader_ip is not None
finally:
leader.stop()
follower.stop()
+239
View File
@@ -781,6 +781,20 @@ class TestNewConfigKeys:
assert cfg.render_width_pct == 100
assert cfg.min_content_separation == 24
def test_width_cap_is_off_by_default(self):
# Capping made wide plugins resume mid-content on every appearance and
# emit runt final windows; it is now opt-in per plugin instead.
assert VegasModeConfig().max_plugin_width_ratio == 0.0
assert VegasModeConfig.from_config({}).max_plugin_width_ratio == 0.0
def test_width_cap_is_still_available_when_asked_for(self):
# Defaulting the cap off must not remove it: a user who sets a ratio
# still gets one, and 0 still means uncapped.
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'max_plugin_width_ratio': 3.0}}})
assert cfg.max_plugin_width_ratio == 3.0
assert cfg.validate() == []
@pytest.mark.parametrize('overrides,bad_key', [
({'render_width_pct': 5}, 'render_width_pct'),
({'render_width_pct': 101}, 'render_width_pct'),
@@ -1643,3 +1657,228 @@ class TestPerPluginWidthBudget:
strip = canvas([(0, 5000)], width=5000)
adapter.get_content(NativePlugin([strip]), 'ticker')
assert adapter._item_offsets.get('ticker', 0) > 0
def ticker(item_widths, gap=32, height=DISPLAY_H):
"""
A strip of discrete items separated by real gaps, like a news or stocks
ticker. Wide enough gaps that blank_runs() sees item boundaries, which is
what puts _crop_to_budget on its item-aligned path rather than treating the
strip as one continuous block.
"""
width = sum(item_widths) + gap * (len(item_widths) - 1)
spans, x = [], 0
for w in item_widths:
spans.append((x, x + w))
x += w + gap
return canvas(spans, width=width, height=height)
class TestTrailingRuntWindow:
"""
A rotation's last window used to be whatever happened to be left over.
Measured on a live 512px panel, a 1,840px stocks ticker against a 1,536px
budget split 1,492 + 348 the second pass showed seven seconds and cut.
"""
def test_a_barely_oversized_strip_is_shown_whole(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
# 1.2 budgets wide: splitting it can only ever produce a fragment.
strip = ticker([180] * 12) # 2160 + 352 gaps = 2512px vs 512 budget
assert strip.width > DISPLAY_W
adapter = adapter_with(content_padding=0,
max_plugin_width_ratio=strip.width / DISPLAY_W * 0.9)
shown = adapter.get_content(NativePlugin([strip]), 'stocks')[0]
assert shown.width == strip.width, "should absorb the runt, not split"
assert 'stocks' not in adapter._item_offsets
def test_no_window_in_a_rotation_is_a_fragment(self):
# Walk a long ticker all the way round; every pass must be worth
# showing rather than one of them being a leftover sliver.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
strip = ticker([150] * 40)
plugin = NativePlugin([strip])
widths, seen_offsets = [], set()
for _ in range(20):
adapter.invalidate_cache('news')
widths.append(adapter.get_content(plugin, 'news')[0].width)
offset = adapter._item_offsets.get('news', 0)
if offset in seen_offsets:
break
seen_offsets.add(offset)
assert len(widths) > 1, "a strip this long must take several passes"
# Item snapping means an ordinary window lands short of the budget, so
# the bar is "not a sliver" rather than "a full budget".
assert min(widths) >= DISPLAY_W // 2, (
"no window should be a fragment, got %r" % widths)
assert max(widths) <= DISPLAY_W * 1.5, (
"absorbing a runt must stay bounded, got %r" % widths)
def test_a_continuous_image_also_absorbs_its_runt(self):
# The no-item-gaps path had the same leftover problem.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
solid = canvas([(0, 700)], width=700) # 512 budget -> 512 + 188 runt
first = adapter.get_content(NativePlugin([solid]), 'chart')[0]
assert first.width == 700, "188px tail is not worth its own pass"
assert 'chart' not in adapter._item_offsets
def test_the_reported_stocks_case(self):
# The exact numbers logged on a 512px panel: an 1,840px stocks ticker
# against a 1,536px budget split 1,492 + 348, so every other appearance
# showed seven seconds of stocks and cut. It should now come through in
# one piece, 20% over budget being the better of the two outcomes.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
# 10 items of 152px with 32px gaps = 1520 + 288 = 1808, near enough.
strip = ticker([152] * 10)
assert DISPLAY_W * 3 < strip.width < DISPLAY_W * 4
widths = []
for _ in range(3):
adapter.invalidate_cache('stocks')
widths.append(adapter.get_content(
NativePlugin([strip]), 'stocks')[0].width)
assert widths == [strip.width] * 3, (
"a strip this close to the budget should be shown whole every "
"time, not split into a big pass and a sliver; got %r" % widths)
def test_a_short_final_row_window_is_not_left_alone(self):
# The multi-row path has the same fault as the single-image one, and
# wrapping does not save it: rows of 450/450/100 against a 512px budget
# gave the 100 a pass of its own, two seconds against nine, because the
# row it wrapped to did not fit either.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0)
rows = [canvas([(0, 450)], width=450),
canvas([(0, 450)], width=450),
canvas([(0, 100)], width=100)]
widths = []
for _ in range(6):
adapter.invalidate_cache('rows')
shown = adapter.get_content(NativePlugin(list(rows)), 'rows')
widths.append(sum(img.width for img in shown))
assert min(widths) >= DISPLAY_W // 2, (
"a row window should not be a sliver, got %r" % widths)
assert max(widths) <= DISPLAY_W * 1.5, (
"absorbing a short row must stay bounded, got %r" % widths)
def test_row_rotation_still_covers_every_row(self):
# Absorbing a short tail must not drop rows from the rotation.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0)
rows = [canvas([(0, 450)], width=450),
canvas([(0, 450)], width=450),
canvas([(0, 100)], width=100)]
seen = set()
for _ in range(8):
adapter.invalidate_cache('rows')
for img in adapter.get_content(NativePlugin(list(rows)), 'rows'):
seen.add(img.width)
assert seen == {450, 100}, "rotation never showed every row: %r" % seen
def test_a_row_too_wide_to_absorb_still_bounds_the_overrun(self):
# When the next row cannot be taken without blowing past 1.5 budgets,
# a short window is the lesser evil — the same trade the always-show-
# the-first-row rule already makes.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0)
rows = [canvas([(0, 900)], width=900), canvas([(0, 100)], width=100)]
for _ in range(4):
adapter.invalidate_cache('wide')
shown = adapter.get_content(NativePlugin(list(rows)), 'wide')
assert sum(i.width for i in shown) <= 900, (
"must not merge a row that overruns the cap")
def test_a_genuinely_long_strip_still_gets_capped(self):
# Absorbing runts must not become "never cap anything".
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
strip = ticker([150] * 60)
shown = adapter.get_content(NativePlugin([strip]), 'long')[0]
assert shown.width < strip.width
assert shown.width <= DISPLAY_W * 2
class TestOffsetOutlivesItsContent:
"""
A rotation offset only means something against the content it was recorded
against. news re-rendered 9,793px -> 9,505px mid-rotation while its stored
column kept advancing, so the window pointed into unrelated headlines.
"""
def test_rotation_survives_items_changing_width(self):
# Same items, each a little wider — a price gaining a digit. The window
# should resume at the same *item*, not at a now-meaningless column.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
adapter.get_content(NativePlugin([ticker([150] * 40)]), 'stocks')
first = adapter._item_offsets.get('stocks')
assert first, "the first pass should leave a resume point"
adapter.invalidate_cache('stocks')
adapter.get_content(NativePlugin([ticker([158] * 40)]), 'stocks')
assert adapter._item_offsets.get('stocks', 0) > first, (
"same item count means the offset still applies and should advance")
def test_rotation_restarts_when_the_item_count_changes(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
adapter.get_content(NativePlugin([ticker([150] * 40)]), 'news')
assert adapter._item_offsets.get('news', 0) > 0
# A fresh headline set with fewer entries: the old position is
# meaningless, so the next pass starts at the top.
adapter.invalidate_cache('news')
shown = adapter.get_content(NativePlugin([ticker([150] * 25)]), 'news')[0]
expected = adapter.get_content(
NativePlugin([ticker([150] * 25)]), 'fresh')[0]
assert shown.width == expected.width
def test_a_row_index_is_never_read_back_as_a_pixel_column(self):
# The unit collision: _apply_width_budget stores an index into a list
# of rows, _crop_to_budget a column in one image, under the same key.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0)
rows = [canvas([(0, 200)], width=200) for _ in range(8)]
adapter.get_content(NativePlugin(rows), 'mixed')
assert adapter._item_offsets.get('mixed', 0) > 0
assert adapter._offset_shapes['mixed'][0] == 'rows'
# Now the same plugin returns one wide strip instead. The row index
# must not be read as a column into it: the strip is entered at the
# top, exactly as it would be for a plugin with no history at all.
strip = ticker([150] * 40)
adapter.invalidate_cache('mixed')
carried = adapter.get_content(NativePlugin([strip]), 'mixed')[0]
assert adapter._offset_shapes['mixed'][0] == 'cuts'
clean = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0)
assert carried.tobytes() == clean.get_content(
NativePlugin([strip]), 'clean')[0].tobytes()
def test_a_stale_index_past_the_end_restarts(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
strip = ticker([150] * 40)
adapter.get_content(NativePlugin([strip]), 'news')
# Force an index far beyond anything the current strip has, keeping the
# shape intact so the guard does not catch it first.
shape = adapter._offset_shapes['news']
adapter._item_offsets['news'] = 10_000
adapter.invalidate_cache('news')
shown = adapter.get_content(NativePlugin([strip]), 'news')[0]
assert shown.width > 0
assert adapter._offset_shapes['news'] == shape
def test_content_that_fits_clears_both_offset_and_shape(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
adapter.get_content(NativePlugin([ticker([150] * 40)]), 'shrink')
assert 'shrink' in adapter._offset_shapes
adapter.invalidate_cache('shrink')
adapter.get_content(NativePlugin([canvas([(0, 100)], width=100)]), 'shrink')
assert 'shrink' not in adapter._item_offsets
assert 'shrink' not in adapter._offset_shapes
+64
View File
@@ -0,0 +1,64 @@
"""Tests the percentile used by the Vegas frame-time log line.
The FPS line reports p99 next to the worst frame, and the point of having both
is that they say different things: p99 is the bad-but-ordinary frame, worst is
the outlier. The obvious index, int(n * 0.99), is off by one and at exactly
100 samples selects the maximum -- so the two columns would report the same
number precisely when the sample was smallest.
"""
import math
import pytest
from src.vegas_mode.coordinator import _percentile
class TestNearestRank:
def test_a_hundred_samples_do_not_return_the_maximum(self):
ordered = [float(i) for i in range(100)] # 0..99
assert _percentile(ordered, 0.99) == 98.0
assert _percentile(ordered, 0.99) != max(ordered)
def test_it_matches_the_nearest_rank_definition(self):
for n in (1, 2, 3, 10, 99, 100, 101, 600, 1000):
ordered = [float(i) for i in range(n)]
expected = ordered[min(n - 1, max(0, math.ceil(n * 0.99) - 1))]
assert _percentile(ordered, 0.99) == expected, n
@pytest.mark.parametrize('fraction,expected', [
(0.0, 0.0), # first
(0.5, 49.0), # median, nearest-rank
(1.0, 99.0), # last
])
def test_other_fractions(self, fraction, expected):
assert _percentile([float(i) for i in range(100)], fraction) == expected
class TestEdges:
def test_empty_is_zero_not_an_error(self):
# The loop calls this before any frame has been timed.
assert _percentile([], 0.99) == 0.0
def test_a_single_sample_is_itself(self):
assert _percentile([4.2], 0.99) == 4.2
def test_it_never_indexes_past_the_end(self):
for n in range(1, 50):
_percentile([float(i) for i in range(n)], 1.0) # must not raise
class TestItSaysSomethingUsefulAboutFrames:
def test_one_freeze_does_not_drag_p99_up(self):
# 599 healthy frames and one 3.2s freeze: p99 should still describe
# the healthy population, while the worst frame is reported separately.
frames = [0.0083] * 599 + [3.2]
p99 = _percentile(sorted(frames), 0.99)
assert p99 == pytest.approx(0.0083), p99
assert max(frames) == 3.2
def test_sustained_slowness_does_move_it(self):
# Ten percent of frames slow is not an outlier, it is the shape of the
# distribution, and p99 must reflect that.
frames = [0.0083] * 540 + [0.05] * 60
assert _percentile(sorted(frames), 0.99) == pytest.approx(0.05)
+248
View File
@@ -0,0 +1,248 @@
"""Tests for surfacing the underlying error in web responses.
Regression under test: every failing endpoint returned "An error occurred; see
logs for details" and nothing else. On a device whose storage was failing that
sentence came back from the restart action, from /system/status, and from
/logs -- the log viewer itself -- because journalctl could not be executed. The
exception underneath said `[Errno 5] Input/output error: 'systemctl'`, which
names the fault outright, and nine handlers were discarding it entirely rather
than even logging it.
"""
import pytest
from src.web_interface.error_handler import describe_exception
class TestDescribeException:
def test_names_the_type_and_message(self):
detail = describe_exception(OSError(5, "Input/output error", "systemctl"))
assert detail == "OSError: [Errno 5] Input/output error: 'systemctl'"
def test_the_reported_failure_is_legible(self):
# The whole point: this string is the diagnosis.
assert "Input/output error" in describe_exception(
OSError(5, "Input/output error", "systemctl"))
def test_a_bare_exception_still_names_its_type(self):
# A PermissionError with no message still says more than "unknown".
assert describe_exception(PermissionError()) == "PermissionError"
assert describe_exception(Exception()) == "Exception"
def test_message_is_kept_when_present(self):
assert describe_exception(ValueError("bad port")) == "ValueError: bad port"
class TestCredentialRedaction:
"""Exception text quotes URLs, and plugins authenticate by query string."""
@pytest.mark.parametrize("secret_text,leaked", [
("failed: https://api.x.com/v1?api_key=SEC123&city=Tampa", "SEC123"),
("token=abcdef123456 was rejected", "abcdef123456"),
("connect failed password=hunter2", "hunter2"),
("GET /?access_token=zzz999", "zzz999"),
('{"secret": "topsecret"}', "topsecret"),
# requests quotes the URL it failed on, and both of these forms turn
# up in real client exceptions.
("401 for https://user:hunter2@example.com/api", "hunter2"),
("headers: {'Authorization': 'Bearer eyJ.SECRET.sig'}", "eyJ.SECRET.sig"),
("Authorization: Basic dXNlcjpwYXNzd29yZA==", "dXNlcjpwYXNzd29yZA=="),
("Proxy-Authorization: Bearer ptok999", "ptok999"),
# Any scheme, not a fixed list -- a list silently leaks whatever it
# does not name, and plugin APIs invent their own.
("Authorization: ApiKey SECRET123", "SECRET123"),
("Authorization: Negotiate YIIZnegotiateblob", "YIIZnegotiateblob"),
("Authorization: NTLM TlRMTVNTUAAB", "TlRMTVNTUAAB"),
("authorization: barecredential", "barecredential"),
])
def test_credentials_never_reach_the_response(self, secret_text, leaked):
detail = describe_exception(RuntimeError(secret_text))
assert leaked not in detail
assert "<redacted>" in detail
def test_the_parameter_name_survives_redaction(self):
# Knowing *which* credential was involved is part of the diagnosis.
detail = describe_exception(RuntimeError("https://x/y?api_key=SEC123"))
assert "api_key" in detail
def test_unknown_schemes_keep_their_name(self):
for scheme in ("ApiKey", "Negotiate", "NTLM", "AWS4-HMAC-SHA256"):
detail = describe_exception(
RuntimeError("Authorization: %s SECRETVALUE" % scheme))
assert scheme in detail, detail
assert "SECRETVALUE" not in detail, detail
def test_auth_scheme_and_username_survive(self):
# Which kind of credential, and whose, without the credential itself.
assert "Bearer" in describe_exception(
RuntimeError("Authorization: Bearer eyJ.SECRET.sig"))
assert "user" in describe_exception(
RuntimeError("https://user:hunter2@example.com"))
def test_non_secret_context_is_preserved(self):
detail = describe_exception(RuntimeError("https://api.x.com/v1?city=Tampa"))
assert "city=Tampa" in detail
assert "<redacted>" not in detail
class TestBounds:
def test_long_messages_are_truncated(self):
detail = describe_exception(ValueError("x" * 5000))
assert len(detail) <= 400
def test_newlines_are_collapsed_to_one_line(self):
detail = describe_exception(ValueError("line one\nline two\tthree"))
assert "\n" not in detail and "\t" not in detail
assert detail == "ValueError: line one line two three"
def test_custom_length_is_honoured(self):
assert len(describe_exception(ValueError("y" * 500), max_length=50)) <= 50
class TestHandlersCarryDetail:
"""The response shape callers actually see."""
def test_no_api_v3_handler_discards_its_exception(self):
"""Every generic-message handler must log a traceback and return detail.
Nine of them bound `e` and never used it, so the promised log entry was
never written either. Checking merely that *something* was logged is
too weak -- a `logger.info("failed")` would satisfy it while throwing
the exception away just as completely, so this asserts the two things
that actually make the failure diagnosable: an error-level record with
the traceback, and the sanitized detail in the response.
"""
import ast
src = open("web_interface/blueprints/api_v3.py").read()
tree = ast.parse(src)
generic = "An error occurred; see logs for details"
def logs_a_traceback(handler):
"""An error/exception-level log call carrying exc_info."""
for call in [n for n in ast.walk(handler) if isinstance(n, ast.Call)]:
func = call.func
if not isinstance(func, ast.Attribute):
continue
if func.attr == "exception": # implies exc_info
return True
if func.attr not in ("error", "critical"):
continue
if any(kw.arg == "exc_info" and getattr(kw.value, "value", False) is True
for kw in call.keywords):
return True
return False
def describes_this_exception(node, bound):
"""A describe_exception(<bound>) call anywhere under `node`."""
for call in [n for n in ast.walk(node) if isinstance(n, ast.Call)]:
if not (isinstance(call.func, ast.Name)
and call.func.id == "describe_exception"):
continue
if bound is None:
return True # bare `except:` cannot name it; accept
if any(isinstance(a, ast.Name) and a.id == bound
for a in call.args):
return True
return False
def returns_the_detail(handler):
"""The detail must be inside what the handler actually returns.
Looking anywhere in the handler is too weak: a handler could
compute describe_exception(e), drop it on the floor, and return the
generic message with no details field, while still passing. So the
call has to appear within a `return` expression.
"""
returns = [n for n in ast.walk(handler) if isinstance(n, ast.Return)]
if not returns:
return False
return all(describes_this_exception(r, handler.name) for r in returns)
offenders = []
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
seg = ast.get_source_segment(src, h) or ""
if generic not in seg:
continue
missing = []
if not logs_a_traceback(h):
missing.append("error-level log with exc_info")
if not returns_the_detail(h):
missing.append("describe_exception(e) in the response")
if missing:
offenders.append((h.lineno, missing))
assert not offenders, (
"handlers returning the generic message without %s: %r"
% ("both a traceback log and the detail", offenders))
def test_client_errors_keep_their_own_status(self):
"""A 405 must not be reported as a server-side UNKNOWN_ERROR.
Werkzeug's HTTPExceptions subclass Exception, so the catch-all saw them
too: a GET on a POST-only route came back 500 "an error occurred",
which tells the caller nothing and blames the wrong side. Found while
probing a device whose POST-only config endpoints answered every GET
with UNKNOWN_ERROR.
"""
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
@app.errorhandler(Exception)
def handle(error):
if isinstance(error, HTTPException):
return jsonify({
"status": "error",
"error_code": (error.name or "HTTP_ERROR").upper().replace(" ", "_"),
"message": error.description,
}), error.code or 500
return jsonify({
"status": "error",
"error_code": "UNKNOWN_ERROR",
"message": "An error occurred; see logs for details",
"details": describe_exception(error),
}), 500
@app.route("/only-post", methods=["POST"])
def only_post():
return jsonify({"ok": True})
@app.route("/boom")
def boom():
raise OSError(5, "Input/output error", "systemctl")
client = app.test_client()
resp = client.get("/only-post")
assert resp.status_code == 405, "a wrong method must stay a 405"
assert resp.get_json()["error_code"] == "METHOD_NOT_ALLOWED"
# A genuine server fault still reports as one, with its detail.
resp = client.get("/boom")
assert resp.status_code == 500
assert "Input/output error" in resp.get_json()["details"]
def test_global_handler_reports_the_underlying_error(self):
from flask import Flask, jsonify
app = Flask(__name__)
@app.errorhandler(Exception)
def handle(error):
return jsonify({
"status": "error",
"error_code": "UNKNOWN_ERROR",
"message": "An error occurred; see logs for details",
"details": describe_exception(error),
}), 500
@app.route("/boom")
def boom():
raise OSError(5, "Input/output error", "systemctl")
client = app.test_client()
body = client.get("/boom").get_json()
assert body["error_code"] == "UNKNOWN_ERROR"
assert "Input/output error" in body["details"]
@@ -0,0 +1,220 @@
"""
Path-containment tests for the backup file routes:
GET /backup/download/<filename>, DELETE /backup/<filename>, and the
listing/validation routes alongside them.
Both filename routes take user input straight from the URL and turn it
into a filesystem path, one to read and one to unlink. `_safe_backup_path`
is what stops that from reaching outside the export directory, and it had
no tests.
This is verification of existing containment, not a fix: no bypass was
found. The tests exist so that a later "just let dots through" change has
to argue with something.
"""
import io
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from web_interface.blueprints import api_v3 as api_v3_module # noqa: E402
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
_MANAGER_ATTRS = (
'config_manager', 'plugin_manager', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
)
_SENTINEL = object()
# Anything that tries to name a file outside the export directory, or that
# is not a plain <name>.zip.
TRAVERSAL_ATTEMPTS = [
"../../etc/passwd",
"../config.json",
"..%2f..%2fetc%2fpasswd",
"....//....//etc/passwd",
"/etc/passwd",
"..\\..\\config.json",
"backup.zip/../../../etc/passwd",
".hidden.zip",
"backup.txt",
"backup.zip.exe",
"",
".",
"..",
]
@pytest.fixture
def env(tmp_path, monkeypatch):
export_dir = tmp_path / "backups"
export_dir.mkdir()
monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", export_dir)
# A file outside the export dir that a traversal would be reaching for.
secret = tmp_path / "config.json"
secret.write_text(json.dumps({"secret": "do not touch"}))
originals = {name: getattr(api_v3, name, _SENTINEL) for name in _MANAGER_ATTRS}
for name in _MANAGER_ATTRS:
setattr(api_v3, name, MagicMock())
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
class Env:
pass
e = Env()
e.client = app.test_client()
e.export_dir = export_dir
e.secret = secret
yield e
for name, original in originals.items():
if original is _SENTINEL:
if hasattr(api_v3, name):
delattr(api_v3, name)
else:
setattr(api_v3, name, original)
def make_backup(export_dir, name="backup-2026-01-01.zip"):
path = export_dir / name
path.write_bytes(b"PK\x03\x04fake zip")
return path
class TestSafeBackupPath:
"""The containment helper itself."""
@pytest.mark.parametrize("filename", TRAVERSAL_ATTEMPTS)
def test_rejects_unsafe_names(self, env, filename):
assert api_v3_module._safe_backup_path(filename) is None
def test_rejects_none(self, env):
assert api_v3_module._safe_backup_path(None) is None
@pytest.mark.parametrize("filename", [
"backup.zip",
"backup-2026-01-01.zip",
"backup_2026.01.01-v2.zip",
"a.zip",
])
def test_accepts_plain_zip_names(self, env, filename):
resolved = api_v3_module._safe_backup_path(filename)
assert resolved is not None
assert resolved.parent == env.export_dir.resolve()
def test_result_is_always_inside_the_export_dir(self, env):
resolved = api_v3_module._safe_backup_path("backup.zip")
resolved.relative_to(env.export_dir.resolve()) # raises if outside
def test_overlong_name_rejected(self, env):
assert api_v3_module._safe_backup_path("a" * 250 + ".zip") is None
class TestDownload:
def test_downloads_an_existing_backup(self, env):
make_backup(env.export_dir)
response = env.client.get("/api/v3/backup/download/backup-2026-01-01.zip")
assert response.status_code == 200
assert response.data == b"PK\x03\x04fake zip"
def test_missing_file_is_a_404(self, env):
response = env.client.get("/api/v3/backup/download/never-made.zip")
assert response.status_code == 404
@pytest.mark.parametrize("filename", TRAVERSAL_ATTEMPTS)
def test_traversal_attempts_are_refused(self, env, filename):
response = env.client.get(f"/api/v3/backup/download/{filename}")
# However the request is turned away — 404 from the containment
# check, or 308/405 from routing never matching at all — what
# matters is that no file outside the export directory is served.
assert response.status_code != 200
assert b"do not touch" not in response.data
class TestDelete:
def test_deletes_an_existing_backup(self, env):
path = make_backup(env.export_dir)
response = env.client.delete("/api/v3/backup/backup-2026-01-01.zip")
assert response.status_code == 200
assert not path.exists()
def test_missing_file_is_a_404(self, env):
response = env.client.delete("/api/v3/backup/never-made.zip")
assert response.status_code == 404
@pytest.mark.parametrize("filename", TRAVERSAL_ATTEMPTS)
def test_traversal_attempts_delete_nothing(self, env, filename):
response = env.client.delete(f"/api/v3/backup/{filename}")
assert response.status_code != 200
assert env.secret.exists() # the file a traversal was aiming at
def test_only_the_named_backup_is_removed(self, env):
keep = make_backup(env.export_dir, "keep.zip")
drop = make_backup(env.export_dir, "drop.zip")
env.client.delete("/api/v3/backup/drop.zip")
assert keep.exists()
assert not drop.exists()
def test_directory_with_a_matching_name_is_not_removed(self, env):
# The delete loop matches by name but requires a regular file.
(env.export_dir / "sneaky.zip").mkdir()
response = env.client.delete("/api/v3/backup/sneaky.zip")
assert response.status_code == 404
assert (env.export_dir / "sneaky.zip").is_dir()
class TestList:
def test_lists_only_zip_files(self, env):
make_backup(env.export_dir, "one.zip")
(env.export_dir / "notes.txt").write_text("ignore me")
response = env.client.get("/api/v3/backup/list")
assert response.status_code == 200
names = [entry["filename"] for entry in response.get_json()["data"]]
assert names == ["one.zip"]
def test_empty_directory_lists_nothing(self, env):
response = env.client.get("/api/v3/backup/list")
assert response.get_json()["data"] == []
def test_entries_carry_size_and_timestamp(self, env):
make_backup(env.export_dir, "one.zip")
entry = env.client.get("/api/v3/backup/list").get_json()["data"][0]
assert entry["size"] == len(b"PK\x03\x04fake zip")
assert entry["created_at"]
class TestValidate:
def test_missing_file_is_a_400(self, env):
response = env.client.post("/api/v3/backup/validate", data={},
content_type="multipart/form-data")
assert response.status_code == 400
assert "No backup_file" in response.get_json()["message"]
def test_invalid_archive_is_a_400(self, env):
response = env.client.post(
"/api/v3/backup/validate",
data={"backup_file": (io.BytesIO(b"not a zip"), "bad.zip")},
content_type="multipart/form-data")
assert response.status_code == 400
assert "Invalid or corrupted" in response.get_json()["message"]
def test_validation_does_not_leave_temp_files_in_the_export_dir(self, env):
env.client.post(
"/api/v3/backup/validate",
data={"backup_file": (io.BytesIO(b"not a zip"), "bad.zip")},
content_type="multipart/form-data")
assert list(env.export_dir.iterdir()) == []
@@ -0,0 +1,262 @@
"""
Endpoint tests for POST /backup/restore.
Restore is the most destructive operation the web interface exposes: it
overwrites config, secrets, WiFi settings and fonts, and reinstalls
plugins. It had no tests.
restore_backup itself is mocked this file is about what the route does
with the request and with the result, not about ZIP handling, which
belongs to backup_manager's own tests.
Regression coverage for one fixed bug: a malformed `options` field fell
back to {}, and since every RestoreOptions flag defaults to True, that
turned a mis-serialized narrow restore into a full one secrets
included with no indication anything had been ignored.
"""
import io
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
URL = "/api/v3/backup/restore"
_MANAGER_ATTRS = (
'config_manager', 'plugin_manager', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
)
_SENTINEL = object()
class FakeResult:
"""Stand-in for backup_manager.RestoreResult."""
def __init__(self, success=True, restored=None, errors=None,
plugins_to_install=None):
self.success = success
self.restored = restored if restored is not None else ["config"]
self.errors = errors or []
self.plugins_to_install = plugins_to_install or []
self.plugins_installed = []
self.plugins_failed = []
def to_dict(self):
return {
"success": self.success,
"restored": self.restored,
"errors": self.errors,
"plugins_installed": self.plugins_installed,
"plugins_failed": self.plugins_failed,
}
@pytest.fixture
def client():
originals = {name: getattr(api_v3, name, _SENTINEL) for name in _MANAGER_ATTRS}
for name in _MANAGER_ATTRS:
setattr(api_v3, name, MagicMock())
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
yield app.test_client()
for name, original in originals.items():
if original is _SENTINEL:
if hasattr(api_v3, name):
delattr(api_v3, name)
else:
setattr(api_v3, name, original)
@pytest.fixture
def restore():
"""Patch backup_manager.restore_backup (imported inside the handler)."""
with patch("src.backup_manager.restore_backup") as mock:
mock.return_value = FakeResult()
yield mock
def post(client, options=None, filename="backup.zip", content=b"PK\x03\x04fake"):
data = {"backup_file": (io.BytesIO(content), filename)}
if options is not None:
data["options"] = options
return client.post(URL, data=data, content_type="multipart/form-data")
class TestRequestValidation:
def test_missing_file_is_a_400(self, client, restore):
response = client.post(URL, data={}, content_type="multipart/form-data")
assert response.status_code == 400
assert "No backup_file" in response.get_json()["message"]
restore.assert_not_called()
def test_absent_options_defaults_to_a_full_restore(self, client, restore):
# Documented default, not the bug: omitting options entirely means
# "restore everything".
post(client)
options = restore.call_args[0][2]
assert options.restore_config is True
assert options.restore_secrets is True
assert options.reinstall_plugins is True
def test_partial_options_are_honoured(self, client, restore):
post(client, options=json.dumps({
"restore_secrets": False, "reinstall_plugins": False}))
options = restore.call_args[0][2]
assert options.restore_secrets is False
assert options.reinstall_plugins is False
assert options.restore_config is True # unspecified stays default
@pytest.mark.parametrize("raw", ["{not json", "", "{'single': 'quotes'}"])
def test_malformed_options_are_refused(self, client, restore, raw):
# Regression: this fell back to {}, and every flag defaults to
# True, so a caller asking for a narrow restore and mis-serializing
# it got a full one — secrets overwritten — and no warning.
response = post(client, options=raw)
assert response.status_code == 400
assert "Invalid options" in response.get_json()["message"]
restore.assert_not_called()
@pytest.mark.parametrize("raw", ["[1,2,3]", '"a string"', "42", "true", "null"])
def test_options_that_are_not_an_object_are_refused(self, client, restore, raw):
response = post(client, options=raw)
assert response.status_code == 400
restore.assert_not_called()
def test_empty_object_is_accepted_as_all_defaults(self, client, restore):
assert post(client, options="{}").status_code == 200
assert restore.call_args[0][2].restore_config is True
class TestSuccess:
def test_success_returns_the_result(self, client, restore):
restore.return_value = FakeResult(success=True, restored=["config", "secrets"])
response = post(client)
assert response.status_code == 200
body = response.get_json()
assert body["status"] == "success"
assert body["data"]["restored"] == ["config", "secrets"]
def test_temp_file_is_cleaned_up(self, client, restore):
seen = {}
def capture(path, project_root, options):
seen["path"] = Path(path)
assert seen["path"].exists() # present while restoring
return FakeResult()
restore.side_effect = capture
post(client)
assert not seen["path"].exists()
def test_temp_file_cleaned_up_even_when_restore_raises(self, client, restore):
seen = {}
def blow_up(path, project_root, options):
seen["path"] = Path(path)
raise RuntimeError("corrupt archive")
restore.side_effect = blow_up
response = post(client)
assert response.status_code == 500
assert not seen["path"].exists()
class TestPluginReinstall:
def test_plugins_are_reinstalled_when_requested(self, client, restore):
restore.return_value = FakeResult(
plugins_to_install=[{"plugin_id": "clock"}, {"plugin_id": "weather"}])
api_v3.plugin_store_manager.install_plugin.return_value = True
response = post(client)
assert response.status_code == 200
assert response.get_json()["data"]["plugins_installed"] == ["clock", "weather"]
def test_reinstall_skipped_when_not_requested(self, client, restore):
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
post(client, options=json.dumps({"reinstall_plugins": False}))
api_v3.plugin_store_manager.install_plugin.assert_not_called()
def test_entries_without_a_plugin_id_are_skipped(self, client, restore):
restore.return_value = FakeResult(plugins_to_install=[{}, {"plugin_id": "clock"}])
api_v3.plugin_store_manager.install_plugin.return_value = True
post(client)
assert api_v3.plugin_store_manager.install_plugin.call_count == 1
def test_failed_reinstall_turns_the_whole_restore_into_an_error(
self, client, restore):
# Pinned as intentional: file restoration succeeded and does not
# touch result.errors, but a user whose plugins did not come back
# should not be told the restore was a success.
restore.return_value = FakeResult(
success=True, plugins_to_install=[{"plugin_id": "clock"}])
api_v3.plugin_store_manager.install_plugin.return_value = False
response = post(client)
assert response.status_code == 500
body = response.get_json()
assert body["status"] == "error"
assert "clock" in body["message"]
def test_message_names_what_landed_and_what_did_not(self, client, restore):
restore.return_value = FakeResult(
success=True, restored=["config", "fonts"],
plugins_to_install=[{"plugin_id": "clock"}])
api_v3.plugin_store_manager.install_plugin.return_value = False
message = post(client).get_json()["message"]
assert "restored: config, fonts" in message
assert "plugins not reinstalled: clock" in message
def test_install_exception_is_recorded_without_leaking_details(
self, client, restore):
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
api_v3.plugin_store_manager.install_plugin.side_effect = RuntimeError(
"/srv/internal/path exploded")
body = post(client).get_json()
failures = body["data"]["plugins_failed"]
assert failures[0]["plugin_id"] == "clock"
assert "/srv/internal/path" not in json.dumps(body)
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
api_v3.plugin_store_manager = None
with patch("web_interface.blueprints.api_v3.plugin_store_manager", None):
body = post(client).get_json()
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
class TestFailureReporting:
def test_restore_errors_produce_a_500(self, client, restore):
restore.return_value = FakeResult(
success=False, restored=[], errors=["config: permission denied"])
response = post(client)
assert response.status_code == 500
assert "permission denied" in response.get_json()["message"]
def test_partial_restore_names_both_sides(self, client, restore):
restore.return_value = FakeResult(
success=False, restored=["config"], errors=["secrets: unwritable"])
message = post(client).get_json()["message"]
assert "restored: config" in message
assert "failed: secrets: unwritable" in message
def test_failure_without_detail_still_says_something(self, client, restore):
restore.return_value = FakeResult(success=False, restored=[], errors=[])
message = post(client).get_json()["message"]
assert "Restore incomplete" in message
def test_unexpected_exception_is_a_500(self, client, restore):
restore.side_effect = RuntimeError("boom")
response = post(client)
assert response.status_code == 500
assert response.get_json()["status"] == "error"
@@ -0,0 +1,204 @@
"""
Endpoint tests for POST /config/raw/main and POST /config/raw/secrets.
These write whatever JSON they are given straight to config.json and
config_secrets.json, bypassing the secret-separation path that
/config/main and the plugin-config endpoints go through. Given how much
care the rest of the config surface takes to keep secrets out of
config.json, an untested pair of endpoints that writes it verbatim is
worth pinning precisely.
Like test_api_v3_secret_roundtrip.py, these run a REAL ConfigManager over
tmp_path so the assertions are against files on disk rather than mock
calls.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from src.config_manager import ConfigManager # noqa: E402
from src.exceptions import ConfigError # noqa: E402
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
MAIN = "/api/v3/config/raw/main"
SECRETS = "/api/v3/config/raw/secrets"
@pytest.fixture
def env(tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"timezone": "UTC"}))
secrets_file = tmp_path / "config_secrets.json"
config_manager = ConfigManager(
config_path=str(config_file), secrets_path=str(secrets_file))
config_manager.template_path = str(tmp_path / "no-template.json")
_SENTINEL = object()
attrs = ('config_manager', 'plugin_manager', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager',
'schema_manager', 'operation_queue', 'operation_history',
'cache_manager')
originals = {name: getattr(api_v3, name, _SENTINEL) for name in attrs}
for name in attrs:
setattr(api_v3, name, MagicMock())
api_v3.config_manager = config_manager
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
class Env:
pass
e = Env()
e.client = app.test_client()
e.config_manager = config_manager
e.config_file = config_file
e.secrets_file = secrets_file
yield e
for name, original in originals.items():
if original is _SENTINEL:
if hasattr(api_v3, name):
delattr(api_v3, name)
else:
setattr(api_v3, name, original)
class TestSaveRawMain:
def test_writes_the_body_to_config_json(self, env):
response = env.client.post(MAIN, json={"timezone": "America/Chicago"})
assert response.status_code == 200
assert json.loads(env.config_file.read_text()) == {"timezone": "America/Chicago"}
def test_replaces_rather_than_merges(self, env):
env.client.post(MAIN, json={"only": "this"})
assert json.loads(env.config_file.read_text()) == {"only": "this"}
def test_does_not_touch_the_secrets_file(self, env):
env.secrets_file.write_text(json.dumps({"weather": {"api_key": "k"}}))
env.client.post(MAIN, json={"timezone": "UTC"})
assert json.loads(env.secrets_file.read_text()) == {"weather": {"api_key": "k"}}
def test_uninitialized_manager_is_a_500(self, env):
api_v3.config_manager = None
response = env.client.post(MAIN, json={"timezone": "UTC"})
assert response.status_code == 500
assert "not initialized" in response.get_json()["message"]
def test_empty_object_is_a_400(self, env):
response = env.client.post(MAIN, json={})
assert response.status_code == 400
assert "No data provided" in response.get_json()["message"]
def test_bodyless_post_is_a_400(self, env):
response = env.client.post(MAIN)
assert response.status_code == 400
assert "No data provided" in response.get_json()["message"]
def test_malformed_json_is_a_400_in_the_app_shape(self, env):
response = env.client.post(MAIN, data="{not json",
content_type="application/json")
assert response.status_code == 400
body = response.get_json()
assert body["status"] == "error"
# A body that was sent but does not parse is a distinct mistake
# from sending none, and says so. Previously the handler's own
# json.JSONDecodeError arm was unreachable — Werkzeug raised
# first — so this collapsed into "No data provided".
assert "Invalid JSON in request body" in body["message"]
def test_config_error_is_a_500_with_context(self, env, monkeypatch):
def refuse(kind, data):
raise ConfigError("cannot write", config_path="/etc/x.json")
monkeypatch.setattr(env.config_manager, "save_raw_file_content", refuse)
response = env.client.post(MAIN, json={"timezone": "UTC"})
assert response.status_code == 500
assert "/etc/x.json" in json.dumps(response.get_json())
def test_unexpected_error_is_a_500(self, env, monkeypatch):
def boom(kind, data):
raise RuntimeError("disk on fire")
monkeypatch.setattr(env.config_manager, "save_raw_file_content", boom)
response = env.client.post(MAIN, json={"timezone": "UTC"})
assert response.status_code == 500
assert response.get_json()["status"] == "error"
class TestSaveRawSecrets:
def test_writes_only_to_the_secrets_file(self, env):
response = env.client.post(SECRETS, json={"weather": {"api_key": "s3cret"}})
assert response.status_code == 200
assert json.loads(env.secrets_file.read_text()) == {"weather": {"api_key": "s3cret"}}
def test_secret_values_never_reach_config_json(self, env):
env.client.post(SECRETS, json={"weather": {"api_key": "s3cret"}})
assert "s3cret" not in env.config_file.read_text()
def test_existing_main_config_is_untouched(self, env):
before = env.config_file.read_text()
env.client.post(SECRETS, json={"weather": {"api_key": "k"}})
assert env.config_file.read_text() == before
def test_github_token_is_reloaded_for_the_store_manager(self, env):
store = MagicMock()
store._load_github_token.return_value = "ghp_new"
api_v3.plugin_store_manager = store
env.client.post(SECRETS, json={"github": {"token": "ghp_new"}})
store._load_github_token.assert_called_once()
assert store.github_token == "ghp_new"
def test_absent_store_manager_is_fine(self, env):
api_v3.plugin_store_manager = None
assert env.client.post(SECRETS, json={"a": 1}).status_code == 200
def test_uninitialized_manager_is_a_500(self, env):
api_v3.config_manager = None
assert env.client.post(SECRETS, json={"a": 1}).status_code == 500
def test_empty_object_is_a_400(self, env):
assert env.client.post(SECRETS, json={}).status_code == 400
def test_bodyless_post_is_a_400(self, env):
assert env.client.post(SECRETS).status_code == 400
def test_error_is_a_500(self, env, monkeypatch):
def boom(kind, data):
raise RuntimeError("nope")
monkeypatch.setattr(env.config_manager, "save_raw_file_content", boom)
assert env.client.post(SECRETS, json={"a": 1}).status_code == 500
class TestRawEndpointsBypassSecretSeparation:
"""Pinned behaviour, deliberately not "fixed".
These endpoints are the escape hatch for editing the config files
directly from the web UI's raw JSON editor. They write what they are
given, so a secret typed into the main-config editor lands in
config.json in plain text unlike /config/main and the plugin-config
endpoints, which route x-secret fields into config_secrets.json.
That is the point of a raw editor, but it is a sharp edge worth
stating out loud: anyone adding a "convenience" that posts plugin
config through this endpoint would silently lose secret separation.
"""
def test_secret_shaped_keys_are_written_verbatim_to_main(self, env):
env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}})
on_disk = json.loads(env.config_file.read_text())
assert on_disk["weather"]["api_key"] == "PLAINTEXT-KEY"
def test_no_separation_happens_on_the_raw_path(self, env):
env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}})
# Nothing was moved aside into the secrets file.
assert not env.secrets_file.exists() or "PLAINTEXT-KEY" not in env.secrets_file.read_text()
@@ -0,0 +1,259 @@
"""
End-to-end secret round-trips through the three api_v3 endpoints that
separate secrets from regular config (main-config save, plugin-config save,
plugin-config reset) now backed by the canonical
src/web_interface/secret_helpers implementations.
Unlike test_web_api.py (which mocks the config manager), these tests run a
REAL ConfigManager and a REAL SchemaManager over tmp_path files, so they
prove the whole chain: endpoint separation -> config_secrets.json write ->
atomic config.json save (strip) -> load_config (merge back), including the
array-item secret shape (accounts[].token) the inline copies never
supported.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from src.config_manager import ConfigManager # noqa: E402
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
PLUGIN_ID = "testplugin"
SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"display_duration": {"type": "number", "default": 15},
"api_key": {"type": "string", "x-secret": True, "default": ""},
"city": {"type": "string", "default": "Austin"},
"accounts": {
"type": "array",
"default": [],
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"token": {"type": "string", "x-secret": True},
},
},
},
},
}
@pytest.fixture
def env(tmp_path):
"""Real ConfigManager + SchemaManager over tmp_path, wired onto the
api_v3 blueprint with the remaining managers mocked."""
config_file = tmp_path / "config.json"
config_file.write_text("{}")
plugins_dir = tmp_path / "plugins"
plugin_dir = plugins_dir / PLUGIN_ID
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.json").write_text(json.dumps(SCHEMA))
(plugin_dir / "manifest.json").write_text(json.dumps({
"id": PLUGIN_ID, "name": "Test Plugin", "version": "1.0.0",
}))
config_manager = ConfigManager(
config_path=str(config_file),
secrets_path=str(tmp_path / "config_secrets.json"))
config_manager.template_path = str(tmp_path / "no-template.json")
schema_manager = SchemaManager(plugins_dir=plugins_dir,
project_root=tmp_path)
plugin_manager = MagicMock()
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}}
plugin_manager.plugins_dir = plugins_dir
plugin_manager.get_plugin.return_value = None
api_v3.config_manager = config_manager
api_v3.schema_manager = schema_manager
api_v3.plugin_manager = plugin_manager
api_v3.plugin_store_manager = MagicMock()
api_v3.saved_repositories_manager = MagicMock()
api_v3.operation_queue = MagicMock()
api_v3.plugin_state_manager = MagicMock()
api_v3.operation_history = MagicMock()
api_v3.cache_manager = MagicMock()
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
class Env:
pass
e = Env()
e.client = app.test_client()
e.config_manager = config_manager
e.config_file = config_file
e.secrets_file = tmp_path / "config_secrets.json"
e.tmp_path = tmp_path
def fresh_load():
"""Load via a NEW ConfigManager, as the next request/process would.
The endpoint's manager serves its post-save in-memory config via the
mtime fast path, and that copy predates the secrets it just
separated out a pre-existing quirk that applies to scalar secrets
too. On-disk truth is what these tests care about.
"""
fresh = ConfigManager(config_path=str(config_file),
secrets_path=str(e.secrets_file))
fresh.template_path = str(tmp_path / "no-template.json")
return fresh.load_config()
e.fresh_load = fresh_load
return e
def _on_disk(path):
return json.loads(path.read_text())
class TestSaveMainConfig:
"""Site A: POST /config/main with a plugin-id key."""
def test_array_and_scalar_secrets_routed_to_secrets_file(self, env):
resp = env.client.post("/api/v3/config/main", json={
PLUGIN_ID: {
"city": "Dallas",
"api_key": "s3cret-key",
"accounts": [
{"name": "a", "token": "s3cret-a"},
{"name": "b"},
],
},
})
assert resp.status_code == 200, resp.get_json()
on_disk = _on_disk(env.config_file)
assert on_disk[PLUGIN_ID]["city"] == "Dallas"
assert "api_key" not in on_disk[PLUGIN_ID]
assert on_disk[PLUGIN_ID]["accounts"] == [{"name": "a"}, {"name": "b"}]
assert "s3cret" not in env.config_file.read_text()
secrets = _on_disk(env.secrets_file)
assert secrets[PLUGIN_ID]["api_key"] == "s3cret-key"
assert secrets[PLUGIN_ID]["accounts"] == [{"token": "s3cret-a"}, {}]
def test_load_config_merges_secrets_back(self, env):
env.client.post("/api/v3/config/main", json={
PLUGIN_ID: {"accounts": [{"name": "a", "token": "s3cret-a"}]},
})
merged = env.fresh_load()
assert merged[PLUGIN_ID]["accounts"] == [
{"name": "a", "token": "s3cret-a"}]
class TestSavePluginConfig:
"""Site B: POST /plugins/config (JSON body)."""
def _save(self, env, config):
return env.client.post("/api/v3/plugins/config", json={
"plugin_id": PLUGIN_ID, "config": config,
})
def test_round_trip_with_array_secrets(self, env):
resp = self._save(env, {
"enabled": True,
"city": "Houston",
"api_key": "s3cret-key",
"accounts": [
{"name": "a", "token": "s3cret-a"},
{"name": "b", "token": "s3cret-b"},
],
})
assert resp.status_code == 200, resp.get_json()
assert "s3cret" not in env.config_file.read_text()
on_disk = _on_disk(env.config_file)
assert on_disk[PLUGIN_ID]["accounts"] == [{"name": "a"}, {"name": "b"}]
secrets = _on_disk(env.secrets_file)
assert secrets[PLUGIN_ID]["accounts"] == [
{"token": "s3cret-a"}, {"token": "s3cret-b"}]
merged = env.fresh_load()
assert merged[PLUGIN_ID]["accounts"][1]["token"] == "s3cret-b"
def test_secret_count_message_counts_top_level_keys(self, env):
# Pinned: the "(N secret field(s))" message counts TOP-LEVEL keys of
# the separated secrets dict. Here that is 2: the posted accounts
# array (all its item tokens count as ONE key) plus the schema's
# api_key default ("") that merge_with_defaults adds before
# separation.
resp = self._save(env, {
"accounts": [{"name": "a", "token": "t"}],
})
message = resp.get_json()["message"]
assert "(2 secret field(s) saved to config_secrets.json)" in message
def test_resave_replaces_stored_secrets_list_wholesale(self, env):
# Characterized: api_v3's deep_merge intentionally replaces lists,
# so a re-save's parallel secrets list is authoritative.
self._save(env, {"accounts": [
{"name": "a", "token": "old-a"},
{"name": "b", "token": "old-b"},
]})
self._save(env, {"accounts": [{"name": "only", "token": "new-only"}]})
secrets = _on_disk(env.secrets_file)
assert secrets[PLUGIN_ID]["accounts"] == [{"token": "new-only"}]
merged = env.fresh_load()
assert merged[PLUGIN_ID]["accounts"] == [
{"name": "only", "token": "new-only"}]
class TestResetPluginConfig:
"""Site C: POST /plugins/config/reset."""
def _seed(self, env):
env.client.post("/api/v3/plugins/config", json={
"plugin_id": PLUGIN_ID,
"config": {"city": "Houston", "api_key": "s3cret-key",
"accounts": [{"name": "a", "token": "s3cret-a"}]},
})
def test_reset_preserving_secrets(self, env):
self._seed(env)
resp = env.client.post("/api/v3/plugins/config/reset", json={
"plugin_id": PLUGIN_ID, "preserve_secrets": True,
})
assert resp.status_code == 200, resp.get_json()
on_disk = _on_disk(env.config_file)
assert on_disk[PLUGIN_ID]["city"] == "Austin" # schema default
assert on_disk[PLUGIN_ID]["accounts"] == [] # schema default
# Existing secrets survive (top-level-only preserve merge, pinned).
secrets = _on_disk(env.secrets_file)
assert secrets[PLUGIN_ID]["api_key"] == "s3cret-key"
assert secrets[PLUGIN_ID]["accounts"] == [{"token": "s3cret-a"}]
def test_reset_without_preserving_secrets(self, env):
self._seed(env)
resp = env.client.post("/api/v3/plugins/config/reset", json={
"plugin_id": PLUGIN_ID, "preserve_secrets": False,
})
assert resp.status_code == 200, resp.get_json()
secrets = _on_disk(env.secrets_file)
# Replaced with schema-default secrets — the schema declares no
# secret defaults, so the plugin's secrets are emptied.
assert secrets[PLUGIN_ID] in ({}, {"api_key": ""})
+149
View File
@@ -0,0 +1,149 @@
"""
Tests for the response builders in src/web_interface/error_handler.py and
the success path in src/web_interface/api_helpers.py.
describe_exception() in the same module is already covered by
test/test_web_error_detail.py and is not duplicated here.
Regression coverage for one fixed bug: create_success_response used
truthiness for `message` and `metadata` while using `is not None` for
`data`, so an explicitly-passed "" or {} was silently dropped
api_helpers.success_response() repeated the same gate, which is the path
every api_v3 endpoint actually calls.
"""
import pytest
from flask import Flask
from src.web_interface.api_helpers import success_response
from src.web_interface.error_handler import (
create_error_response,
create_success_response,
)
from src.web_interface.errors import ErrorCode, WebInterfaceError
@pytest.fixture
def app():
return Flask(__name__)
class TestCreateErrorResponse:
def test_returns_response_and_status_tuple(self, app):
with app.test_request_context():
response, status = create_error_response(
ErrorCode.CONFIG_SAVE_FAILED, "could not save")
assert status == 500
assert response.get_json()["message"] == "could not save"
def test_status_code_passthrough(self, app):
with app.test_request_context():
_, status = create_error_response(
ErrorCode.INVALID_INPUT, "bad", status_code=400)
assert status == 400
def test_body_matches_the_error_dataclass(self, app):
with app.test_request_context():
response, _ = create_error_response(
ErrorCode.NETWORK_ERROR, "offline",
details="connection refused", context={"url": "http://x"})
expected = WebInterfaceError(
error_code=ErrorCode.NETWORK_ERROR, message="offline",
details="connection refused", context={"url": "http://x"}).to_dict()
assert response.get_json() == expected
def test_none_context_produces_no_context_key(self, app):
with app.test_request_context():
response, _ = create_error_response(ErrorCode.SYSTEM_ERROR, "boom")
assert "context" not in response.get_json()
def test_suggested_fixes_passed_through(self, app):
with app.test_request_context():
response, _ = create_error_response(
ErrorCode.SYSTEM_ERROR, "boom", suggested_fixes=["Try again"])
assert response.get_json()["suggested_fixes"] == ["Try again"]
class TestCreateSuccessResponse:
def test_bare_success(self):
assert create_success_response() == {"status": "success"}
def test_data_included(self):
assert create_success_response(data={"a": 1})["data"] == {"a": 1}
@pytest.mark.parametrize("falsy", [0, "", False, {}, []])
def test_falsy_data_is_still_included(self, falsy):
assert create_success_response(data=falsy)["data"] == falsy
def test_none_data_omitted(self):
assert "data" not in create_success_response(data=None)
def test_message_included(self):
assert create_success_response(message="done")["message"] == "done"
def test_empty_message_is_still_included(self):
# Regression: `if message:` dropped an explicitly-passed "".
assert create_success_response(message="")["message"] == ""
def test_none_message_omitted(self):
assert "message" not in create_success_response(message=None)
def test_metadata_included(self):
assert create_success_response(metadata={"v": 1})["metadata"] == {"v": 1}
def test_empty_metadata_is_still_included(self):
# Regression: `if metadata:` dropped an explicitly-passed {}.
assert create_success_response(metadata={})["metadata"] == {}
def test_none_metadata_omitted(self):
assert "metadata" not in create_success_response(metadata=None)
class TestSuccessResponseHelper:
"""api_helpers.success_response — the wrapper every endpoint calls."""
def test_plain_response_has_no_metadata_block(self, app):
with app.test_request_context():
body = success_response(data={"a": 1}).get_json()
assert body == {"status": "success", "data": {"a": 1}}
def test_explicit_empty_metadata_survives_the_wrapper(self, app):
# Regression: the wrapper re-gated metadata on truthiness after
# create_success_response had already included it, so {} was
# dropped again on the way out.
with app.test_request_context():
body = success_response(data=None, metadata={}).get_json()
assert body["metadata"] == {}
def test_caller_metadata_preserved(self, app):
with app.test_request_context():
body = success_response(metadata={"version": "1.2"}).get_json()
assert body["metadata"]["version"] == "1.2"
def test_timing_added_when_request_has_start_time(self, app):
with app.test_request_context() as ctx:
ctx.request.start_time = 0.0
body = success_response(data={"a": 1}).get_json()
assert "response_time_ms" in body["metadata"]
def test_timing_merges_with_caller_metadata(self, app):
with app.test_request_context() as ctx:
ctx.request.start_time = 0.0
body = success_response(metadata={"version": "1.2"}).get_json()
assert body["metadata"]["version"] == "1.2"
assert "response_time_ms" in body["metadata"]
def test_caller_metadata_dict_is_not_mutated(self, app):
# The helper used to add response_time_ms straight into the dict the
# caller passed, so a module-level or reused metadata dict would
# accumulate timings from previous requests.
caller_metadata = {"version": "1.2"}
with app.test_request_context() as ctx:
ctx.request.start_time = 0.0
success_response(metadata=caller_metadata)
assert caller_metadata == {"version": "1.2"}
def test_message_passed_through(self, app):
with app.test_request_context():
body = success_response(message="saved").get_json()
assert body["message"] == "saved"
+208
View File
@@ -0,0 +1,208 @@
"""
Tests for src/web_interface/errors.py the structured error type behind
every API error response (category inference, default suggestions, the
JSON shape, and exception conversion).
Pure logic; no Flask context needed.
Regression coverage for one fixed bug: suggested_fixes used `or`, so a
caller passing [] to mean "no suggestions" silently got the default list.
"""
import pytest
from src.web_interface.errors import ErrorCategory, ErrorCode, WebInterfaceError
class TestCategoryInference:
@pytest.mark.parametrize("code,expected", [
(ErrorCode.CONFIG_SAVE_FAILED, ErrorCategory.CONFIGURATION),
(ErrorCode.CONFIG_ROLLBACK_FAILED, ErrorCategory.CONFIGURATION),
(ErrorCode.PLUGIN_NOT_FOUND, ErrorCategory.PLUGIN),
(ErrorCode.PLUGIN_OPERATION_CONFLICT, ErrorCategory.PLUGIN),
(ErrorCode.VALIDATION_ERROR, ErrorCategory.VALIDATION),
(ErrorCode.SCHEMA_VALIDATION_FAILED, ErrorCategory.VALIDATION),
(ErrorCode.INVALID_INPUT, ErrorCategory.VALIDATION),
(ErrorCode.NETWORK_ERROR, ErrorCategory.NETWORK),
(ErrorCode.API_ERROR, ErrorCategory.NETWORK),
(ErrorCode.TIMEOUT, ErrorCategory.NETWORK),
(ErrorCode.PERMISSION_DENIED, ErrorCategory.PERMISSION),
(ErrorCode.FILE_PERMISSION_ERROR, ErrorCategory.PERMISSION),
(ErrorCode.SYSTEM_ERROR, ErrorCategory.SYSTEM),
(ErrorCode.SERVICE_UNAVAILABLE, ErrorCategory.SYSTEM),
(ErrorCode.UNKNOWN_ERROR, ErrorCategory.UNKNOWN),
])
def test_every_code_prefix_maps_to_its_category(self, code, expected):
assert WebInterfaceError(code, "msg").category is expected
def test_explicit_category_overrides_inference(self):
error = WebInterfaceError(
ErrorCode.CONFIG_SAVE_FAILED, "msg", category=ErrorCategory.SYSTEM)
assert error.category is ErrorCategory.SYSTEM
def test_every_error_code_gets_a_category(self):
# No code may fall through uncategorized as the enum grows.
for code in ErrorCode:
assert isinstance(WebInterfaceError(code, "msg").category, ErrorCategory)
class TestDefaultSuggestions:
def test_mapped_code_gets_specific_suggestions(self):
fixes = WebInterfaceError(ErrorCode.CONFIG_SAVE_FAILED, "msg").suggested_fixes
assert "Check available disk space" in fixes
def test_unmapped_code_gets_generic_fallback(self):
# PLUGIN_UPDATE_FAILED has no entry in suggestions_map.
fixes = WebInterfaceError(ErrorCode.PLUGIN_UPDATE_FAILED, "msg").suggested_fixes
assert fixes == ["Review error details and try again"]
def test_explicit_suggestions_win(self):
error = WebInterfaceError(
ErrorCode.CONFIG_SAVE_FAILED, "msg", suggested_fixes=["Do the thing"])
assert error.suggested_fixes == ["Do the thing"]
def test_explicit_empty_list_is_respected(self):
# Regression: `suggested_fixes or default` treated [] as "unset",
# so a caller could not express "I have no suggestions".
error = WebInterfaceError(
ErrorCode.CONFIG_SAVE_FAILED, "msg", suggested_fixes=[])
assert error.suggested_fixes == []
def test_none_still_gets_defaults(self):
error = WebInterfaceError(
ErrorCode.CONFIG_SAVE_FAILED, "msg", suggested_fixes=None)
assert len(error.suggested_fixes) > 0
class TestToDict:
def test_base_keys_always_present(self):
result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict()
assert result["status"] == "error"
assert result["error_code"] == "SYSTEM_ERROR"
assert result["error_category"] == "system"
assert result["message"] == "boom"
def test_details_included_when_set(self):
result = WebInterfaceError(
ErrorCode.SYSTEM_ERROR, "boom", details="disk full").to_dict()
assert result["details"] == "disk full"
def test_details_omitted_when_absent(self):
assert "details" not in WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict()
def test_context_included_when_non_empty(self):
result = WebInterfaceError(
ErrorCode.SYSTEM_ERROR, "boom", context={"path": "/tmp/x"}).to_dict()
assert result["context"] == {"path": "/tmp/x"}
def test_empty_context_is_omitted(self):
# Pinned as intentional, not a bug: __init__ normalizes context to
# {}, and an empty context carries no information, so it is left out
# rather than padding every error body with "context": {}.
result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom", context={}).to_dict()
assert "context" not in result
def test_empty_suggestions_omitted(self):
result = WebInterfaceError(
ErrorCode.SYSTEM_ERROR, "boom", suggested_fixes=[]).to_dict()
assert "suggested_fixes" not in result
def test_is_json_serializable(self):
import json
error = WebInterfaceError(
ErrorCode.NETWORK_ERROR, "boom",
details="timeout", context={"url": "http://x"})
assert json.loads(json.dumps(error.to_dict()))["error_code"] == "NETWORK_ERROR"
class TestFromException:
@pytest.mark.parametrize("exc_name,expected", [
("ConfigError", ErrorCode.CONFIG_LOAD_FAILED),
("PluginError", ErrorCode.PLUGIN_LOAD_FAILED),
("PermissionError", ErrorCode.PERMISSION_DENIED),
("AccessDenied", ErrorCode.PERMISSION_DENIED),
("ValidationError", ErrorCode.VALIDATION_ERROR),
("SchemaError", ErrorCode.VALIDATION_ERROR),
("NetworkError", ErrorCode.NETWORK_ERROR),
("ConnectionError", ErrorCode.NETWORK_ERROR),
("TimeoutError", ErrorCode.TIMEOUT),
("SomethingElse", ErrorCode.UNKNOWN_ERROR),
])
def test_code_inferred_from_exception_class_name(self, exc_name, expected):
exc = type(exc_name, (Exception,), {})("boom")
assert WebInterfaceError.from_exception(exc).error_code is expected
def test_explicit_code_skips_inference(self):
error = WebInterfaceError.from_exception(
ValueError("boom"), error_code=ErrorCode.PLUGIN_NOT_FOUND)
assert error.error_code is ErrorCode.PLUGIN_NOT_FOUND
def test_message_is_the_safe_one_not_the_exception_text(self):
# The raw exception text is not echoed into `message`; that field is
# a fixed, user-facing string per code.
error = WebInterfaceError.from_exception(ValueError("secret-ish detail"))
assert error.message == "An unexpected error occurred"
assert "secret-ish" not in error.message
def test_exception_type_recorded_in_context(self):
error = WebInterfaceError.from_exception(ValueError("boom"))
assert error.context["exception_type"] == "ValueError"
def test_caller_context_is_preserved_alongside_type(self):
error = WebInterfaceError.from_exception(
ValueError("boom"), context={"plugin_id": "clock"})
assert error.context["plugin_id"] == "clock"
assert error.context["exception_type"] == "ValueError"
def test_caller_supplied_exception_type_is_overwritten(self):
error = WebInterfaceError.from_exception(
ValueError("boom"), context={"exception_type": "Fake"})
assert error.context["exception_type"] == "ValueError"
def test_original_error_retained(self):
exc = ValueError("boom")
assert WebInterfaceError.from_exception(exc).original_error is exc
def test_every_code_has_a_safe_message(self):
for code in ErrorCode:
assert WebInterfaceError._safe_message(code)
class TestExceptionDetails:
def test_context_dict_is_flattened(self):
exc = ValueError("boom")
exc.context = {"config_path": "/etc/x.json", "line": 4}
details = WebInterfaceError._get_exception_details(exc)
assert "config_path: /etc/x.json" in details
assert "line: 4" in details
assert "; " in details
def test_exception_type_key_excluded(self):
exc = ValueError("boom")
exc.context = {"exception_type": "ValueError", "path": "/tmp/x"}
details = WebInterfaceError._get_exception_details(exc)
assert "exception_type" not in details
assert details == "path: /tmp/x"
def test_context_with_only_exception_type_gives_none(self):
exc = ValueError("boom")
exc.context = {"exception_type": "ValueError"}
assert WebInterfaceError._get_exception_details(exc) is None
def test_no_context_attribute_gives_none(self):
assert WebInterfaceError._get_exception_details(ValueError("boom")) is None
def test_non_dict_context_gives_none(self):
exc = ValueError("boom")
exc.context = "not a dict"
assert WebInterfaceError._get_exception_details(exc) is None
def test_empty_context_gives_none(self):
exc = ValueError("boom")
exc.context = {}
assert WebInterfaceError._get_exception_details(exc) is None
def test_details_flow_into_from_exception(self):
exc = ValueError("boom")
exc.context = {"config_path": "/etc/x.json"}
assert "config_path" in WebInterfaceError.from_exception(exc).details
@@ -1,18 +1,13 @@
"""
Drift guard for the duplicated secret-separation logic.
Drift guard: api_v3 must use the canonical secret helpers.
src/web_interface/secret_helpers.py is the canonical implementation of
find_secret_fields / separate_secrets, but web_interface/blueprints/api_v3.py
still carries THREE inline nested-function copies of each (in the plugin
config GET, POST, and reset endpoints). The copies lack the canonical
module's array-item support (`accounts[].token`), so migrating an endpoint
onto the module is a behavior change that must be made deliberately.
This file guards two things:
1. The copy count can only go DOWN. A fourth copy appearing means someone
re-implemented the logic again instead of importing secret_helpers.
2. The known behavioral gap is documented as an executable fact, so whoever
migrates the endpoints knows exactly what changes.
Historically web_interface/blueprints/api_v3.py carried THREE inline
nested-function copies of ``find_secret_fields``/``separate_secrets`` (in the
main-config save, plugin-config save, and plugin-config reset endpoints).
They lacked the canonical module's array-item secret support and drifted from
each other. They have been migrated onto
``src/web_interface/secret_helpers`` this file now guards against copies
REAPPEARING, and keeps the canonical array-item behavior executable.
"""
import re
@@ -23,77 +18,44 @@ from src.web_interface.secret_helpers import find_secret_fields, separate_secret
API_V3_PATH = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3.py")
# Update DOWNWARD as endpoints migrate onto src/web_interface/secret_helpers.
EXPECTED_INLINE_COPIES = 3
# The migration is complete: any inline reimplementation is a regression.
EXPECTED_INLINE_COPIES = 0
class TestInlineCopyCount:
class TestNoInlineCopies:
def _count(self, name: str) -> int:
source = API_V3_PATH.read_text(encoding="utf-8")
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
def test_find_secret_fields_copy_count(self):
def test_no_inline_find_secret_fields(self):
count = self._count("find_secret_fields")
assert count == EXPECTED_INLINE_COPIES, (
f"api_v3.py has {count} inline find_secret_fields definitions, "
f"expected {EXPECTED_INLINE_COPIES}. New code must import it from "
f"src/web_interface/secret_helpers instead of re-implementing it; "
f"if you migrated an endpoint, lower EXPECTED_INLINE_COPIES."
f"expected {EXPECTED_INLINE_COPIES}. Import it from "
f"src/web_interface/secret_helpers instead of re-implementing it."
)
def test_separate_secrets_copy_count(self):
def test_no_inline_separate_secrets(self):
count = self._count("separate_secrets")
assert count == EXPECTED_INLINE_COPIES, (
f"api_v3.py has {count} inline separate_secrets definitions, "
f"expected {EXPECTED_INLINE_COPIES}. New code must import it from "
f"src/web_interface/secret_helpers instead of re-implementing it; "
f"if you migrated an endpoint, lower EXPECTED_INLINE_COPIES."
f"expected {EXPECTED_INLINE_COPIES}. Import it from "
f"src/web_interface/secret_helpers instead of re-implementing it."
)
def test_inline_copies_lack_array_item_support(self):
"""The documented gap: no inline copy recurses into array `items`
schemas, so array-item secrets (accounts[].token) are NOT routed to
config_secrets.json by these endpoints. The canonical module handles
them. When an endpoint migrates onto the module that behavior
changes (a fix, but a deliberate one).
If this fails, an inline copy has grown array support duplicating
the canonical module even harder. Migrate the endpoint onto
src/web_interface/secret_helpers instead.
"""
for body in self._inline_bodies("find_secret_fields"):
# Array handling requires checking type == 'array'; no inline
# copy does. (Can't grep bare "items" — properties.items() the
# dict method appears legitimately.)
assert "'array'" not in body and '"array"' not in body
@staticmethod
def _inline_bodies(name: str):
"""Extract each inline def's body from api_v3.py by indentation."""
lines = API_V3_PATH.read_text(encoding="utf-8").splitlines()
bodies = []
i = 0
while i < len(lines):
match = re.match(rf"^(\s+)def {name}\(", lines[i])
if not match:
i += 1
continue
indent = len(match.group(1))
body = [lines[i]]
i += 1
while i < len(lines):
line = lines[i]
if line.strip() and (len(line) - len(line.lstrip())) <= indent:
break
body.append(line)
i += 1
bodies.append("\n".join(body))
assert bodies, f"no inline {name} definitions found"
return bodies
def test_canonical_import_present(self):
# Tripwire: the endpoints still need the helpers, so removing the
# import means either dead secret handling or a new local copy.
source = API_V3_PATH.read_text(encoding="utf-8")
assert re.search(
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
source,
), "api_v3.py no longer imports the canonical secret helpers"
class TestCanonicalArrayItemBehavior:
"""Executable documentation of what migrating endpoints will change."""
"""Executable documentation of the array-item secret contract the
endpoints now inherit from the canonical module."""
SCHEMA = {
"accounts": {
+284
View File
@@ -0,0 +1,284 @@
"""
Tests for src/web_interface/validators.py.
dedup_unique_arrays is already covered by test_dedup_unique_arrays.py and
is not repeated here; this file covers the other eight functions, none of
which had any tests.
Regression coverage for three fixed bugs:
- validate_numeric_range accepted True/False, since bool subclasses int.
- validate_file_upload lowercased the filename's extension but not the
caller's allowed_extensions list, so ['.TTF'] rejected 'font.ttf'.
- validate_image_url only checked for '..' inside the relative-path
branch, so http://host/../secret passed validation untouched.
"""
import pytest
from src.web_interface.validators import (
escape_html,
sanitize_plugin_config,
validate_file_upload,
validate_font_awesome_class,
validate_image_url,
validate_mime_type,
validate_numeric_range,
validate_string_length,
)
class TestEscapeHtml:
def test_escapes_all_five_entities(self):
assert escape_html("""<a href="x">O'Neill & co</a>""") == (
"&lt;a href=&quot;x&quot;&gt;O&#x27;Neill &amp; co&lt;/a&gt;")
def test_ampersand_is_escaped_first_so_nothing_double_escapes(self):
# If '<' were replaced before '&', the '&' of '&lt;' would be
# escaped again into '&amp;lt;'.
assert escape_html("<") == "&lt;"
assert escape_html("&") == "&amp;"
assert escape_html("&<") == "&amp;&lt;"
def test_plain_text_unchanged(self):
assert escape_html("hello world") == "hello world"
def test_non_string_is_coerced(self):
assert escape_html(42) == "42"
assert escape_html(None) == "None"
def test_script_tag_neutralized(self):
assert "<script>" not in escape_html("<script>alert(1)</script>")
class TestValidateImageUrl:
@pytest.mark.parametrize("url", [
"javascript:alert(1)",
"JavaScript:alert(1)",
"JAVASCRIPT:alert(1)",
"data:text/html;base64,PHNjcmlwdD4=",
"vbscript:msgbox(1)",
"file:///etc/passwd",
])
def test_dangerous_protocols_rejected(self, url):
valid, error = validate_image_url(url)
assert valid is False and "protocol" in error.lower()
@pytest.mark.parametrize("url", [
"http://x/a.png?onerror=alert(1)",
"http://x/a.png#onload=alert(1)",
"http://x/onclick=alert(1).png",
])
def test_event_handlers_rejected(self, url):
valid, error = validate_image_url(url)
assert valid is False and "Event handlers" in error
@pytest.mark.parametrize("url", ["", None, 123, []])
def test_empty_or_non_string_rejected(self, url):
assert validate_image_url(url)[0] is False
def test_http_and_https_allowed(self):
assert validate_image_url("http://example.com/logo.png") == (True, None)
assert validate_image_url("https://example.com/logo.png") == (True, None)
def test_other_schemes_rejected(self):
valid, error = validate_image_url("ftp://example.com/logo.png")
assert valid is False and "http://" in error
def test_relative_path_allowed(self):
assert validate_image_url("/static/logo.png") == (True, None)
def test_protocol_relative_url_rejected(self):
assert validate_image_url("//evil.com/logo.png")[0] is False
def test_relative_traversal_rejected(self):
assert validate_image_url("/static/../../etc/passwd")[0] is False
def test_absolute_url_traversal_rejected(self):
# Regression: the '..' check used to sit inside the leading-slash
# branch, so an absolute URL skipped it entirely.
valid, error = validate_image_url("http://example.com/../secret")
assert valid is False and "traversal" in error.lower()
def test_bare_traversal_rejected(self):
assert validate_image_url("../../etc/passwd")[0] is False
class TestValidateFontAwesomeClass:
@pytest.mark.parametrize("cls", ["fa-star", "fas fa-star", "fa-solid fa-house"])
def test_valid_classes_accepted(self, cls):
assert validate_font_awesome_class(cls) == (True, None)
@pytest.mark.parametrize("cls", ["star", "glyphicon-star", ""])
def test_classes_without_fa_prefix_rejected(self, cls):
assert validate_font_awesome_class(cls)[0] is False
def test_injection_attempt_rejected(self):
assert validate_font_awesome_class('fa-star" onload="alert(1)')[0] is False
def test_angle_brackets_rejected(self):
assert validate_font_awesome_class("<script>fa-star</script>")[0] is False
def test_non_string_rejected(self):
valid, error = validate_font_awesome_class(None)
assert valid is False and "string" in error
def test_explicit_fa_check_is_unreachable_but_harmless(self):
# Characterized, not fixed: the regex already requires 'fa-', so the
# follow-up `if 'fa-' not in class_name` can never fire. Anything
# lacking 'fa-' is rejected by the pattern first, with the pattern's
# own message.
valid, error = validate_font_awesome_class("star")
assert valid is False
assert error == "Invalid Font Awesome class name format"
class TestValidateFileUpload:
def test_plain_filename_accepted(self):
assert validate_file_upload("logo.png") == (True, None)
@pytest.mark.parametrize("filename", [
"../etc/passwd", "dir/file.png", "dir\\file.png", "..\\..\\secrets",
])
def test_traversal_characters_rejected(self, filename):
valid, error = validate_file_upload(filename)
assert valid is False and "invalid characters" in error
@pytest.mark.parametrize("filename", ["", None, 123])
def test_empty_or_non_string_rejected(self, filename):
assert validate_file_upload(filename)[0] is False
def test_allowed_extension_accepted(self):
assert validate_file_upload("font.ttf", allowed_extensions=[".ttf", ".otf"]) == (True, None)
def test_disallowed_extension_rejected(self):
valid, error = validate_file_upload("evil.exe", allowed_extensions=[".ttf"])
assert valid is False and "extension" in error
def test_uppercase_filename_extension_matches(self):
assert validate_file_upload("FONT.TTF", allowed_extensions=[".ttf"]) == (True, None)
def test_uppercase_allowed_list_matches(self):
# Regression: only the filename side was lowercased, so a caller
# passing ['.TTF'] rejected every valid .ttf upload.
assert validate_file_upload("font.ttf", allowed_extensions=[".TTF"]) == (True, None)
def test_no_extension_list_skips_the_check(self):
assert validate_file_upload("anything.xyz") == (True, None)
class TestValidateMimeType:
def test_known_type_accepted(self):
assert validate_mime_type("logo.png", ["image/png"]) == (True, None)
def test_mismatched_type_rejected(self):
valid, error = validate_mime_type("logo.png", ["image/jpeg"])
assert valid is False and "not allowed" in error
def test_undeterminable_type_rejected(self):
valid, error = validate_mime_type("mystery.zzz", ["image/png"])
assert valid is False and "Could not determine" in error
def test_guess_type_failure_is_caught(self, monkeypatch):
import mimetypes
monkeypatch.setattr(mimetypes, "guess_type",
lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("boom")))
valid, error = validate_mime_type("logo.png", ["image/png"])
assert valid is False and "Error validating MIME type" in error
class TestValidateNumericRange:
def test_value_in_range(self):
assert validate_numeric_range(5, min_val=0, max_val=10) == (True, None)
def test_boundaries_are_inclusive(self):
assert validate_numeric_range(0, min_val=0, max_val=10) == (True, None)
assert validate_numeric_range(10, min_val=0, max_val=10) == (True, None)
def test_below_minimum_rejected(self):
valid, error = validate_numeric_range(-1, min_val=0)
assert valid is False and "at least" in error
def test_above_maximum_rejected(self):
valid, error = validate_numeric_range(11, max_val=10)
assert valid is False and "at most" in error
def test_floats_accepted(self):
assert validate_numeric_range(2.5, min_val=0, max_val=10) == (True, None)
def test_no_bounds_accepts_any_number(self):
assert validate_numeric_range(-9999) == (True, None)
@pytest.mark.parametrize("value", ["5", None, [], {}])
def test_non_numeric_rejected(self, value):
valid, error = validate_numeric_range(value, min_val=0, max_val=10)
assert valid is False and error == "Value must be a number"
@pytest.mark.parametrize("value", [True, False])
def test_booleans_rejected(self, value):
# Regression: bool subclasses int, so True passed the isinstance
# check and then compared as 1 against the range.
valid, error = validate_numeric_range(value, min_val=0, max_val=10)
assert valid is False and error == "Value must be a number"
class TestValidateStringLength:
def test_within_range(self):
assert validate_string_length("hello", min_length=1, max_length=10) == (True, None)
def test_boundaries_are_inclusive(self):
assert validate_string_length("abc", min_length=3, max_length=3) == (True, None)
def test_too_short_rejected(self):
valid, error = validate_string_length("", min_length=1)
assert valid is False and "at least" in error
def test_too_long_rejected(self):
valid, error = validate_string_length("abcdef", max_length=3)
assert valid is False and "at most" in error
def test_non_string_rejected(self):
valid, error = validate_string_length(123, max_length=10)
assert valid is False and "must be a string" in error
def test_no_bounds_accepts_anything(self):
assert validate_string_length("") == (True, None)
class TestSanitizePluginConfig:
def test_valid_keys_and_scalars_kept(self):
config = {"enabled": True, "count": 3, "ratio": 1.5, "name": "clock"}
assert sanitize_plugin_config(config) == config
@pytest.mark.parametrize("key", ["has space", "has-dash", "has.dot", "has/slash", ""])
def test_invalid_key_names_dropped(self, key):
assert sanitize_plugin_config({key: "value", "good": 1}) == {"good": 1}
def test_non_string_keys_dropped(self):
assert sanitize_plugin_config({1: "a", "good": 2}) == {"good": 2}
def test_nested_dicts_recursed(self):
result = sanitize_plugin_config({"outer": {"inner": 1, "bad key": 2}})
assert result == {"outer": {"inner": 1}}
def test_list_of_scalars_preserved(self):
assert sanitize_plugin_config({"teams": ["PHI", "NYG"]})["teams"] == ["PHI", "NYG"]
def test_list_of_dicts_recursed(self):
result = sanitize_plugin_config({"items": [{"ok": 1, "bad key": 2}]})
assert result["items"] == [{"ok": 1}]
def test_unknown_value_types_dropped(self):
assert sanitize_plugin_config({"weird": {1, 2, 3}, "good": 1}) == {"good": 1}
def test_none_values_dropped(self):
assert sanitize_plugin_config({"nothing": None, "good": 1}) == {"good": 1}
def test_strings_are_not_html_escaped(self):
# Pinned, not a bug: escaping here would persist the escaped form in
# config.json. Output escaping belongs to the template layer, which
# the function's docstring now says explicitly.
payload = "<script>alert(1)</script>"
assert sanitize_plugin_config({"title": payload})["title"] == payload
def test_empty_config(self):
assert sanitize_plugin_config({}) == {}
+33 -3
View File
@@ -16,6 +16,8 @@ from datetime import datetime, timedelta
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config_manager import ConfigManager
from src.web_interface.error_handler import describe_exception
from werkzeug.exceptions import HTTPException
from src.exceptions import ConfigError
from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.store_manager import PluginStoreManager
@@ -391,15 +393,42 @@ def internal_error(error):
import logging
logger = logging.getLogger('web_interface')
logger.error("Internal server error", exc_info=True)
return jsonify({
payload = {
'status': 'error',
'error_code': 'INTERNAL_ERROR',
'message': 'An internal error occurred; see logs for details',
}), 500
}
# Flask hands the original exception over as `error.original_exception`
# when propagation is off; without it there is nothing to describe.
original = getattr(error, 'original_exception', None) or (
error if isinstance(error, BaseException) else None)
if original is not None:
payload['details'] = describe_exception(original)
return jsonify(payload), 500
@app.errorhandler(Exception)
def handle_exception(error):
"""Handle all unhandled exceptions."""
"""Handle all unhandled exceptions.
Returning only "see logs for details" is fine until the logs are exactly
what you cannot reach. A device with failing storage answered every
endpoint with that sentence -- including the log viewer, because journalctl
could not be executed -- while the exception underneath said
`[Errno 5] Input/output error`. Naming the error costs nothing here and is
frequently the whole diagnosis, so include it alongside the log pointer.
"""
# Werkzeug's HTTPExceptions subclass Exception, so this catch-all sees
# them too and was reporting every 405, 400, 413 and 415 as a server-side
# UNKNOWN_ERROR 500. A GET on a POST-only route came back as "an error
# occurred" rather than "method not allowed", which tells the caller
# nothing and blames the wrong side. Hand those back as themselves.
if isinstance(error, HTTPException):
return jsonify({
'status': 'error',
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
'message': error.description,
}), error.code or 500
import logging
logger = logging.getLogger('web_interface')
logger.error("Unhandled exception", exc_info=True)
@@ -407,6 +436,7 @@ def handle_exception(error):
'status': 'error',
'error_code': 'UNKNOWN_ERROR',
'message': 'An error occurred; see logs for details',
'details': describe_exception(error),
}), 500
# Captive portal redirect middleware
File diff suppressed because it is too large Load Diff
@@ -117,6 +117,14 @@
</select>
</div>
<div class="form-group" id="setting-display-orientation" data-setting-key="display.hardware.orientation">
<label for="orientation" class="block text-sm font-medium text-gray-700">Panel Orientation{{ ui.help_tip('Rotates the rendered image to match how the panel is physically mounted.\nUse "Upside Down" if you flipped the panel 180° to move the Raspberry Pi / wiring to a more convenient side.', 'Panel Orientation') }}</label>
<select id="orientation" name="orientation" class="form-control">
<option value="normal" {% if main_config.display.hardware.get('orientation', 'normal') == "normal" %}selected{% endif %}>Normal</option>
<option value="180" {% if main_config.display.hardware.get('orientation', 'normal') == "180" %}selected{% endif %}>Upside Down (180°)</option>
</select>
</div>
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label>
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
@@ -556,11 +564,11 @@
</div>
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (020).\nDefault: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', 'Max Plugin Width') }}</label>
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (020).\nDefault: 0 (no limit) — every plugin shows all of its content and always starts at the beginning.\nSet a limit to stop one long ticker holding the display for minutes: it is cut to this width and the remainder shown on later cycles. The trade-off is that such a plugin then resumes mid-content on each appearance instead of starting fresh.', 'Max Plugin Width') }}</label>
<input type="number"
id="vegas_max_plugin_width_ratio"
name="vegas_max_plugin_width_ratio"
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 3.0) }}"
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 0.0) }}"
min="0"
max="20"
step="0.5"