mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-11 13:48:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad279a7d2 |
@@ -41,6 +41,7 @@ from src.plugin_system.testing.loading import ( # noqa: E402
|
|||||||
)
|
)
|
||||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||||
|
check_empty_claimed,
|
||||||
check_scale_up,
|
check_scale_up,
|
||||||
)
|
)
|
||||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
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", {})
|
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||||
fill_strict = spec.get("fill_check") == "strict"
|
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" —
|
# Every run: the base config, plus one per harness.json "variant" —
|
||||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
# 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)
|
compare_to_goldens(results, golden_dir)
|
||||||
|
|
||||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
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.
|
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||||
if variant_name:
|
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
|
# warn-only underfill: big panel left mostly empty
|
||||||
ex, ey = r.fill_extent
|
ex, ey = r.fill_extent
|
||||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
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:
|
else:
|
||||||
everything_ok = False
|
everything_ok = False
|
||||||
if r.error is not None:
|
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)
|
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||||
status = "FAIL"
|
status = "FAIL"
|
||||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
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:
|
else:
|
||||||
status, detail = "FAIL", ""
|
status, detail = "FAIL", ""
|
||||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ class RenderResult:
|
|||||||
golden_ok: Optional[bool] = None
|
golden_ok: Optional[bool] = None
|
||||||
golden_diff_pixels: int = 0
|
golden_diff_pixels: int = 0
|
||||||
golden_max_delta: 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 / scale-up check (populated only for sizes >= 2x the design size)
|
||||||
fill_checked: bool = False
|
fill_checked: bool = False
|
||||||
fill_ok: Optional[bool] = None # False only in strict mode
|
fill_ok: Optional[bool] = None # False only in strict mode
|
||||||
@@ -92,6 +97,8 @@ class RenderResult:
|
|||||||
return False
|
return False
|
||||||
if self.fill_ok is False:
|
if self.fill_ok is False:
|
||||||
return False
|
return False
|
||||||
|
if self.empty_ok is False:
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -132,21 +139,25 @@ def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
|||||||
return plugin_instance
|
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
|
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||||
drive the plugin's internal mode state machine (first display() call renders
|
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)
|
sig = inspect.signature(plugin_instance.display)
|
||||||
if "display_mode" in sig.parameters:
|
if "display_mode" in sig.parameters:
|
||||||
plugin_instance.display(force_clear=True, display_mode=mode)
|
return plugin_instance.display(force_clear=True, display_mode=mode)
|
||||||
return
|
|
||||||
|
|
||||||
modes = getattr(plugin_instance, "modes", None)
|
modes = getattr(plugin_instance, "modes", None)
|
||||||
if modes and mode in modes:
|
if modes and mode in modes:
|
||||||
plugin_instance.current_mode_index = list(modes).index(mode)
|
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||||
if hasattr(plugin_instance, "current_display_mode"):
|
if hasattr(plugin_instance, "current_display_mode"):
|
||||||
plugin_instance.current_display_mode = None
|
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]):
|
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",
|
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||||
plugin_id, mode, e)
|
plugin_id, mode, e)
|
||||||
if result.error is None:
|
if result.error is None:
|
||||||
_render_mode(inst, mode)
|
result.display_returned = _render_mode(inst, mode)
|
||||||
result.image = dm.get_image()
|
result.image = dm.get_image()
|
||||||
result.overflow = dm.check_overflow()
|
result.overflow = dm.check_overflow()
|
||||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
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)
|
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],
|
def check_scale_up(results: List[RenderResult],
|
||||||
design_size: Tuple[int, int] = (128, 32),
|
design_size: Tuple[int, int] = (128, 32),
|
||||||
min_extent: float = _MIN_FILL_EXTENT,
|
min_extent: float = _MIN_FILL_EXTENT,
|
||||||
|
|||||||
@@ -68,21 +68,6 @@ class PluginAdapter:
|
|||||||
# always the same opening items.
|
# always the same opening items.
|
||||||
self._item_offsets: dict = {}
|
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(
|
logger.info(
|
||||||
"PluginAdapter initialized: display=%dx%d",
|
"PluginAdapter initialized: display=%dx%d",
|
||||||
self.display_width, self.display_height
|
self.display_width, self.display_height
|
||||||
@@ -413,88 +398,6 @@ class PluginAdapter:
|
|||||||
return 0
|
return 0
|
||||||
return int(self.display_width * ratio)
|
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(
|
def _apply_width_budget(
|
||||||
self, images: List[Image.Image], plugin_id: str,
|
self, images: List[Image.Image], plugin_id: str,
|
||||||
plugin: Optional['BasePlugin'] = None
|
plugin: Optional['BasePlugin'] = None
|
||||||
@@ -532,47 +435,31 @@ class PluginAdapter:
|
|||||||
|
|
||||||
if not budget or total <= budget:
|
if not budget or total <= budget:
|
||||||
# Fits, so reset rotation — the whole segment is being shown.
|
# Fits, so reset rotation — the whole segment is being shown.
|
||||||
self._clear_offset(plugin_id)
|
self._item_offsets.pop(plugin_id, None)
|
||||||
return images
|
return images
|
||||||
|
|
||||||
if len(images) == 1:
|
if len(images) == 1:
|
||||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
||||||
|
|
||||||
shape = ('rows', len(images))
|
|
||||||
if mode == 'truncate':
|
if mode == 'truncate':
|
||||||
# Ordered content: always show from the top. Deliberately does not
|
# Ordered content: always show from the top. Deliberately does not
|
||||||
# advance the offset, so the same opening items appear every time
|
# advance the offset, so the same opening items appear every time
|
||||||
# rather than the viewer being shown the middle of a ranked list.
|
# rather than the viewer being shown the middle of a ranked list.
|
||||||
start = 0
|
start = 0
|
||||||
else:
|
else:
|
||||||
start = self._resume_offset(plugin_id, shape) % len(images)
|
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
||||||
selected: List[Image.Image] = []
|
selected: List[Image.Image] = []
|
||||||
used = 0
|
used = 0
|
||||||
consumed = 0
|
consumed = 0
|
||||||
|
|
||||||
# Walk forward from the rotation offset, taking whole items only, so a
|
# Walk forward from the rotation offset, taking whole items only, so a
|
||||||
# cut never lands in the middle of one.
|
# 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)):
|
for step in range(len(images)):
|
||||||
img = images[(start + step) % len(images)]
|
img = images[(start + step) % len(images)]
|
||||||
cost = img.width
|
cost = img.width
|
||||||
if selected:
|
if selected:
|
||||||
cost += self._row_gap(selected[-1], img)
|
cost += self._row_gap(selected[-1], img)
|
||||||
if selected and used + cost > budget:
|
if selected and used + cost > budget:
|
||||||
# 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
|
break
|
||||||
selected.append(img)
|
selected.append(img)
|
||||||
used += cost
|
used += cost
|
||||||
@@ -585,8 +472,7 @@ class PluginAdapter:
|
|||||||
plugin_id, budget, len(selected), len(images), used
|
plugin_id, budget, len(selected), len(images), used
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._record_offset(
|
self._item_offsets[plugin_id] = (start + consumed) % len(images)
|
||||||
plugin_id, (start + consumed) % len(images), shape)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
||||||
"from offset %d; remainder deferred to a later cycle",
|
"from offset %d; remainder deferred to a later cycle",
|
||||||
@@ -604,13 +490,16 @@ class PluginAdapter:
|
|||||||
|
|
||||||
The cut is snapped to the nearest blank column so it does not slice
|
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.
|
through a glyph or logo and leave half a character at the panel edge.
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
# Cut only where the plugin left a real gap between items. Snapping to
|
# Cut only where the plugin left a real gap between items. Snapping to
|
||||||
# any blank column used to pick the single-column gaps between
|
# any blank column used to pick the single-column gaps between
|
||||||
# characters, splitting a word and orphaning its tail into the next
|
# characters, splitting a word and orphaning its tail into the next
|
||||||
@@ -625,17 +514,9 @@ class PluginAdapter:
|
|||||||
# budget exactly. The gap rule exists to protect discrete items
|
# budget exactly. The gap rule exists to protect discrete items
|
||||||
# (words, ticker entries); it would be wrong to let a solid image
|
# (words, ticker entries); it would be wrong to let a solid image
|
||||||
# escape the cap in its name.
|
# 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':
|
if mode != 'truncate':
|
||||||
self._record_offset(
|
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||||
plugin_id, 0 if end >= img.width else end, shape)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
||||||
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
|
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
|
||||||
@@ -647,15 +528,8 @@ class PluginAdapter:
|
|||||||
# Cut mid-gap so the content either side keeps some breathing room.
|
# 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})
|
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
|
||||||
|
|
||||||
shape = ('cuts', len(cuts))
|
start = max((c for c in cuts if c <= offset), default=0)
|
||||||
index = 0 if mode == 'truncate' else self._resume_offset(
|
later = [c for c in cuts if c > start]
|
||||||
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:
|
if not later:
|
||||||
end = img.width
|
end = img.width
|
||||||
else:
|
else:
|
||||||
@@ -663,22 +537,15 @@ class PluginAdapter:
|
|||||||
# No boundary inside the budget: take the next one and overrun,
|
# No boundary inside the budget: take the next one and overrun,
|
||||||
# because the alternative is cutting through an item.
|
# because the alternative is cutting through an item.
|
||||||
end = max(within) if within else min(later)
|
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':
|
if mode != 'truncate':
|
||||||
# Next cycle resumes at the boundary this one stopped on; wrap when
|
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
||||||
# the strip ends.
|
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||||
self._record_offset(
|
|
||||||
plugin_id, 0 if end >= img.width else end_index, shape)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
||||||
"(%dpx) at item boundaries %d-%d of %d, %s",
|
"(%dpx) at item boundaries, %s",
|
||||||
plugin_id, budget, img.width, start, end, end - start,
|
plugin_id, budget, img.width, start, end, end - start,
|
||||||
start_index, end_index, len(cuts) - 1,
|
|
||||||
"showing the start only (overflow=truncate)"
|
"showing the start only (overflow=truncate)"
|
||||||
if mode == 'truncate' else "window advances next cycle"
|
if mode == 'truncate' else "window advances next cycle"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1643,228 +1643,3 @@ class TestPerPluginWidthBudget:
|
|||||||
strip = canvas([(0, 5000)], width=5000)
|
strip = canvas([(0, 5000)], width=5000)
|
||||||
adapter.get_content(NativePlugin([strip]), 'ticker')
|
adapter.get_content(NativePlugin([strip]), 'ticker')
|
||||||
assert adapter._item_offsets.get('ticker', 0) > 0
|
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user