Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 cad279a7d2 test(harness): flag a mode that draws nothing without reporting it
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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-09 17:22:15 -04:00
5 changed files with 193 additions and 384 deletions
+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 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}")
+55 -6
View File
@@ -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,
+19 -152
View File
@@ -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"
) )
+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
-225
View File
@@ -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