diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py
index 63fbb7e9..0960e65a 100644
--- a/scripts/check_plugin.py
+++ b/scripts/check_plugin.py
@@ -37,10 +37,11 @@ os.environ['EMULATOR'] = 'true'
from src.logging_config import get_logger # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
- find_plugin_dir, load_config_defaults, load_harness_spec,
+ find_plugin_dir, load_config_defaults, load_harness_spec, load_manifest,
)
from src.plugin_system.testing.harness import ( # noqa: E402
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
+ check_scale_up,
)
from src.plugin_system.testing.sizes import ( # noqa: E402
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
@@ -110,28 +111,55 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
effective_freeze = freeze_time or spec.get("freeze_time")
effective_run_update = run_update and not spec.get("skip_update", False)
- results = render_plugin_matrix(
- plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
- mock_data=effective_mock_data, sizes=effective_sizes,
- run_update=effective_run_update, freeze_time=effective_freeze,
- )
+ # The plugin's declared design size drives the scale-up fill check
+ # (panels >= 2x the design size must not be left mostly empty).
+ 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"
- golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden')
- if update_golden:
- written = write_goldens(results, golden_dir)
- logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir)
- else:
- compare_to_goldens(results, golden_dir)
+ # Every run: the base config, plus one per harness.json "variant" —
+ # a config overlay with its own golden dir (e.g. adaptive layout mode
+ # tested alongside the classic default).
+ runs = [(None, {}, golden_dir_override or (plugin_dir / 'test' / 'golden'))]
+ for variant in spec.get("variants", []):
+ name = variant.get("name") or "variant"
+ vdir = plugin_dir / variant.get("golden_dir", f"test/golden-{name}")
+ runs.append((name, variant.get("config", {}), vdir))
- if out_dir:
- for r in results:
- if r.image is None:
- continue
- dest = out_dir / plugin_id / size_label(r.width, r.height)
- dest.mkdir(parents=True, exist_ok=True)
- r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
+ all_run_results: List[RenderResult] = []
+ for variant_name, overlay, golden_dir in runs:
+ run_config = {**full_config, **overlay}
+ results = render_plugin_matrix(
+ plugin_id=plugin_id, plugin_dir=plugin_dir, config=run_config,
+ mock_data=effective_mock_data, sizes=effective_sizes,
+ run_update=effective_run_update, freeze_time=effective_freeze,
+ )
- return results
+ if update_golden:
+ written = write_goldens(results, golden_dir)
+ logger.info("Wrote %d golden image(s) for %s%s to %s", written, plugin_id,
+ f" [{variant_name}]" if variant_name else "", golden_dir)
+ else:
+ compare_to_goldens(results, golden_dir)
+
+ check_scale_up(results, design_size=design_size, strict=fill_strict)
+
+ # Tag variant runs so the report and PNG dumps stay distinguishable.
+ if variant_name:
+ for r in results:
+ r.mode = f"{r.mode}@{variant_name}"
+
+ if out_dir:
+ for r in results:
+ if r.image is None:
+ continue
+ dest = out_dir / plugin_id / size_label(r.width, r.height)
+ dest.mkdir(parents=True, exist_ok=True)
+ r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
+
+ all_run_results.extend(results)
+
+ return all_run_results
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
@@ -147,6 +175,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
detail = " (golden ✓)"
if r.update_error is not None:
detail += f" (update warn: {r.update_error})"
+ if r.fill_checked and r.fill_ok is None and r.fill_extent:
+ # warn-only underfill: big panel left mostly empty
+ ex, ey = r.fill_extent
+ detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
else:
everything_ok = False
if r.error is not None:
@@ -156,6 +188,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
elif r.golden_ok is False:
status = "FAIL"
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
+ elif r.fill_ok is False:
+ ex, ey = r.fill_extent or (0.0, 0.0)
+ status = "FAIL"
+ detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
else:
status, detail = "FAIL", ""
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
diff --git a/scripts/dev_server.py b/scripts/dev_server.py
index 18374405..105f42a8 100644
--- a/scripts/dev_server.py
+++ b/scripts/dev_server.py
@@ -176,46 +176,13 @@ def api_plugin_defaults(plugin_id):
return jsonify({'defaults': defaults})
-@app.route('/api/render', methods=['POST'])
-def api_render():
- """Render a plugin and return the display as base64 PNG."""
- data = request.get_json()
- if not data or 'plugin_id' not in data:
- return jsonify({'error': 'plugin_id is required'}), 400
+def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
+ skip_update):
+ """Render one plugin at one size. Returns the /api/render response dict.
- plugin_id = data['plugin_id']
- user_config = data.get('config', {})
- mock_data = data.get('mock_data', {})
- skip_update = data.get('skip_update', False)
-
- try:
- width = int(data.get('width', 128))
- height = int(data.get('height', 32))
- except (TypeError, ValueError):
- return jsonify({'error': 'width and height must be integers'}), 400
-
- if not (MIN_WIDTH <= width <= MAX_WIDTH):
- return jsonify({'error': f'width must be between {MIN_WIDTH} and {MAX_WIDTH}'}), 400
- if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
- return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
-
- # Find plugin
- plugin_dir = find_plugin_dir(plugin_id)
- if not plugin_dir:
- return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
-
- # Load manifest
- manifest_path = plugin_dir / 'manifest.json'
- with open(manifest_path, 'r') as f:
- manifest = json.load(f)
-
- # Build config: schema defaults + user overrides
- config_defaults = load_config_defaults(plugin_dir)
- config = {'enabled': True}
- config.update(config_defaults)
- config.update(user_config)
-
- # Create display manager and mocks
+ A fresh plugin instance per call, mirroring the safety harness, so sizes
+ never share state.
+ """
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
from src.plugin_system.plugin_loader import PluginLoader
@@ -227,24 +194,20 @@ def api_render():
for key, value in mock_data.items():
cache_manager.set(key, value)
- # Load plugin
loader = PluginLoader()
errors = []
warnings = []
- try:
- plugin_instance, module = loader.load_plugin(
- plugin_id=plugin_id,
- manifest=manifest,
- plugin_dir=plugin_dir,
- config=config,
- display_manager=display_manager,
- cache_manager=cache_manager,
- plugin_manager=plugin_manager,
- install_deps=False,
- )
- except Exception as e:
- return jsonify({'error': f'Failed to load plugin: {e}'}), 500
+ plugin_instance, _module = loader.load_plugin(
+ plugin_id=plugin_id,
+ manifest=manifest,
+ plugin_dir=plugin_dir,
+ config=config,
+ display_manager=display_manager,
+ cache_manager=cache_manager,
+ plugin_manager=plugin_manager,
+ install_deps=False,
+ )
start_time = time.time()
@@ -263,14 +226,115 @@ def api_render():
render_time_ms = round((time.time() - start_time) * 1000, 1)
- return jsonify({
+ return {
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
'width': width,
'height': height,
'render_time_ms': render_time_ms,
'errors': errors,
'warnings': warnings,
- })
+ }
+
+
+def _parse_render_request(data):
+ """Shared /api/render* request prep. Returns (plugin_dir, manifest, config,
+ mock_data, skip_update) or raises ValueError with a client message."""
+ plugin_id = data['plugin_id']
+ plugin_dir = find_plugin_dir(plugin_id)
+ if not plugin_dir:
+ raise LookupError(f'Plugin not found: {plugin_id}')
+
+ manifest_path = plugin_dir / 'manifest.json'
+ with open(manifest_path, 'r') as f:
+ manifest = json.load(f)
+
+ # Build config: schema defaults + user overrides
+ config = {'enabled': True}
+ config.update(load_config_defaults(plugin_dir))
+ config.update(data.get('config', {}))
+
+ return plugin_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
+
+
+@app.route('/api/render', methods=['POST'])
+def api_render():
+ """Render a plugin and return the display as base64 PNG."""
+ data = request.get_json()
+ if not data or 'plugin_id' not in data:
+ return jsonify({'error': 'plugin_id is required'}), 400
+
+ try:
+ width = int(data.get('width', 128))
+ height = int(data.get('height', 32))
+ except (TypeError, ValueError):
+ return jsonify({'error': 'width and height must be integers'}), 400
+
+ if not (MIN_WIDTH <= width <= MAX_WIDTH):
+ return jsonify({'error': f'width must be between {MIN_WIDTH} and {MAX_WIDTH}'}), 400
+ if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
+ return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
+
+ try:
+ plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
+ except LookupError as e:
+ return jsonify({'error': str(e)}), 404
+
+ try:
+ result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
+ mock_data, width, height, skip_update)
+ except Exception as e:
+ return jsonify({'error': f'Failed to load plugin: {e}'}), 500
+ return jsonify(result)
+
+
+@app.route('/api/sizes')
+def api_sizes():
+ """The representative panel-size sample the safety harness renders at."""
+ from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
+ return jsonify({'sizes': [list(s) for s in DEFAULT_TEST_SIZES]})
+
+
+MAX_MATRIX_SIZES = 12
+
+
+@app.route('/api/render-matrix', methods=['POST'])
+def api_render_matrix():
+ """Render a plugin at a list of sizes (default: the harness sample) so the
+ UI can show a side-by-side multi-resolution gallery."""
+ data = request.get_json()
+ if not data or 'plugin_id' not in data:
+ return jsonify({'error': 'plugin_id is required'}), 400
+
+ from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
+ sizes = data.get('sizes') or [list(s) for s in DEFAULT_TEST_SIZES]
+ if len(sizes) > MAX_MATRIX_SIZES:
+ return jsonify({'error': f'at most {MAX_MATRIX_SIZES} sizes per request'}), 400
+ parsed_sizes = []
+ for pair in sizes:
+ try:
+ w, h = int(pair[0]), int(pair[1])
+ except (TypeError, ValueError, IndexError):
+ return jsonify({'error': f'invalid size entry {pair!r} (expected [w, h])'}), 400
+ if not (MIN_WIDTH <= w <= MAX_WIDTH and MIN_HEIGHT <= h <= MAX_HEIGHT):
+ return jsonify({'error': f'size {w}x{h} out of bounds'}), 400
+ parsed_sizes.append((w, h))
+
+ try:
+ plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
+ except LookupError as e:
+ return jsonify({'error': str(e)}), 404
+
+ results = []
+ for w, h in parsed_sizes:
+ try:
+ results.append(_render_once(data['plugin_id'], plugin_dir, manifest,
+ config, mock_data, w, h, skip_update))
+ except Exception as e:
+ results.append({'image': None, 'width': w, 'height': h,
+ 'render_time_ms': 0,
+ 'errors': [f'Failed to load plugin: {e}'],
+ 'warnings': []})
+ return jsonify({'results': results})
# --------------------------------------------------------------------------
diff --git a/scripts/templates/dev_preview.html b/scripts/templates/dev_preview.html
index 84756a66..4e308013 100644
--- a/scripts/templates/dev_preview.html
+++ b/scripts/templates/dev_preview.html
@@ -209,6 +209,11 @@
onchange="onConfigChange()">
px
+
@@ -242,13 +247,18 @@
-
+
+
@@ -311,6 +321,15 @@
+
+
+
@@ -340,8 +359,30 @@
opt.textContent = `${p.name} (${p.id})`;
select.appendChild(opt);
});
+
+ // Load harness size presets
+ try {
+ const sizesRes = await fetch('/api/sizes');
+ const sizesData = await sizesRes.json();
+ const preset = document.getElementById('sizePreset');
+ (sizesData.sizes || []).forEach(([w, h]) => {
+ const opt = document.createElement('option');
+ opt.value = `${w}x${h}`;
+ opt.textContent = `${w} x ${h}`;
+ preset.appendChild(opt);
+ });
+ } catch (e) { /* presets are a convenience; ignore */ }
});
+ function applySizePreset() {
+ const value = document.getElementById('sizePreset').value;
+ if (!value) return;
+ const [w, h] = value.split('x');
+ document.getElementById('displayWidth').value = w;
+ document.getElementById('displayHeight').value = h;
+ onConfigChange();
+ }
+
// ---------- Plugin selection ----------
async function onPluginChange() {
const pluginId = document.getElementById('pluginSelect').value;
@@ -485,6 +526,89 @@
}
}
+ // ---------- Multi-size gallery ----------
+ async function renderAllSizes() {
+ if (!currentPluginId) return;
+
+ const btn = document.getElementById('renderAllBtn');
+ const panel = document.getElementById('galleryPanel');
+ const grid = document.getElementById('galleryGrid');
+ const status = document.getElementById('galleryStatus');
+ btn.disabled = true;
+ btn.textContent = 'Rendering…';
+ panel.classList.remove('hidden');
+ grid.innerHTML = '';
+ status.textContent = 'Rendering at all harness sizes…';
+
+ const config = jsonEditor ? jsonEditor.getValue() : {};
+ config.enabled = true;
+ let mockData = {};
+ const mockInput = document.getElementById('mockDataInput').value.trim();
+ if (mockInput) {
+ try { mockData = JSON.parse(mockInput); }
+ catch (e) { showMessages([], [`Mock data JSON error: ${e.message}`]); }
+ }
+
+ try {
+ const res = await fetch('/api/render-matrix', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ plugin_id: currentPluginId,
+ config: config,
+ mock_data: mockData,
+ }),
+ });
+ const data = await res.json();
+ if (data.error) {
+ status.textContent = data.error;
+ return;
+ }
+
+ let failures = 0;
+ (data.results || []).forEach(r => {
+ const cell = document.createElement('div');
+ cell.style.cssText = 'display:flex;flex-direction:column;gap:4px;';
+ const failed = (r.errors || []).length > 0 || !r.image;
+ if (failed) failures++;
+
+ const label = document.createElement('span');
+ label.className = 'text-xs font-mono';
+ label.style.color = failed ? '#f87171' : 'var(--text-secondary)';
+ label.textContent = `${r.width}x${r.height} · ${r.render_time_ms}ms`;
+ cell.appendChild(label);
+
+ if (r.image) {
+ const img = document.createElement('img');
+ img.src = r.image;
+ // Small panels get 2x zoom so they stay legible in the grid
+ const zoom = r.height >= 128 ? 1 : 2;
+ img.style.cssText =
+ `image-rendering: pixelated; width:${r.width * zoom}px; ` +
+ `height:${r.height * zoom}px; ` +
+ `border:1px solid ${failed ? '#f87171' : 'var(--border-color)'};`;
+ cell.appendChild(img);
+ }
+ if (failed) {
+ const err = document.createElement('span');
+ err.className = 'text-xs font-mono';
+ err.style.color = '#f87171';
+ err.textContent = (r.errors || ['render failed']).join('; ');
+ cell.appendChild(err);
+ }
+ grid.appendChild(cell);
+ });
+ status.textContent = failures
+ ? `${failures} size(s) failed`
+ : `${(data.results || []).length} sizes rendered`;
+ } catch (e) {
+ status.textContent = `Network error: ${e.message}`;
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'All Sizes';
+ }
+ }
+
// ---------- Zoom ----------
function updateZoom() {
const zoom = parseInt(document.getElementById('zoomSlider').value);
diff --git a/src/plugin_system/testing/harness.py b/src/plugin_system/testing/harness.py
index 21c44235..ee4d6613 100644
--- a/src/plugin_system/testing/harness.py
+++ b/src/plugin_system/testing/harness.py
@@ -73,6 +73,10 @@ class RenderResult:
golden_ok: Optional[bool] = None
golden_diff_pixels: int = 0
golden_max_delta: int = 0
+ # 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
+ fill_extent: Optional[Tuple[float, float]] = None # (extent_x, extent_y)
@property
def size_label(self) -> str:
@@ -86,6 +90,8 @@ class RenderResult:
return False
if self.golden_checked and self.golden_ok is False:
return False
+ if self.fill_ok is False:
+ return False
return True
@@ -301,6 +307,74 @@ def compare_to_goldens(results: List[RenderResult], golden_dir: Path,
return results
+# ---------------------------------------------------------------------------
+# Fill / scale-up check
+# ---------------------------------------------------------------------------
+#
+# Overflow catches content that is too BIG for a panel; nothing catches
+# content that stays tiny on a panel much larger than the plugin's design
+# size (e.g. 128x32 content in the corner of a 256x128 renders "green").
+# These helpers measure how much of the panel the lit content spans so the
+# harness can flag plugins that don't scale up.
+
+# A pixel counts as "lit" above this luminance — low enough to catch dim
+# content, high enough to ignore near-black noise.
+_LIT_THRESHOLD = 16
+# Content must span at least this fraction of an axis that is >= 2x the
+# design size. Lenient on purpose: margins are fine, a tiny corner is not.
+_MIN_FILL_EXTENT = 0.5
+
+
+def fill_metrics(image: Image.Image) -> Tuple[float, float, float]:
+ """Measure lit-content coverage: (extent_x, extent_y, ink_ratio).
+
+ extent_* are the lit bounding box's spans as fractions of the panel;
+ ink_ratio is the fraction of pixels lit (reporting only — sparse pixel
+ fonts legitimately have low ink ratios)."""
+ lit = image.convert("L").point(lambda p: 255 if p > _LIT_THRESHOLD else 0)
+ bbox = lit.getbbox()
+ if bbox is None:
+ return (0.0, 0.0, 0.0)
+ extent_x = (bbox[2] - bbox[0]) / image.width
+ extent_y = (bbox[3] - bbox[1]) / image.height
+ ink = sum(1 for p in lit.getdata() if p) / (image.width * image.height)
+ return (extent_x, extent_y, ink)
+
+
+def check_scale_up(results: List[RenderResult],
+ design_size: Tuple[int, int] = (128, 32),
+ min_extent: float = _MIN_FILL_EXTENT,
+ strict: bool = False) -> List[RenderResult]:
+ """Flag renders that leave a big panel mostly empty.
+
+ For each result whose panel is at least 2x the design size on an axis,
+ require the lit content to span >= min_extent of that axis. Mutates the
+ results' fill_* fields. In the default warn-only mode fill_ok is left
+ None (reported, never failing); strict=True sets fill_ok=False, which
+ fails RenderResult.ok — opt in per plugin via harness.json
+ {"fill_check": "strict"} once its adaptive layout is in place.
+ """
+ design_w, design_h = design_size
+ for r in results:
+ if r.image is None or r.error is not None:
+ continue
+ check_x = r.width >= 2 * design_w
+ check_y = r.height >= 2 * design_h
+ if not (check_x or check_y):
+ continue
+ extent_x, extent_y, _ink = fill_metrics(r.image)
+ r.fill_checked = True
+ r.fill_extent = (round(extent_x, 3), round(extent_y, 3))
+ underfilled = ((check_x and extent_x < min_extent)
+ or (check_y and extent_y < min_extent))
+ if underfilled and strict:
+ r.fill_ok = False
+ elif not underfilled:
+ r.fill_ok = True
+ # warn-only underfill: fill_ok stays None; fill_extent tells the story
+ return results
+
+
def write_goldens(results: List[RenderResult], golden_dir: Path) -> int:
"""Write each successfully-rendered result to its golden path. Returns count."""
written = 0
diff --git a/src/plugin_system/testing/loading.py b/src/plugin_system/testing/loading.py
index ed692d5f..f4971ec3 100644
--- a/src/plugin_system/testing/loading.py
+++ b/src/plugin_system/testing/loading.py
@@ -56,7 +56,15 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"config": {...}, # config overrides
"mock_data": "fixtures/mock.json", # path (relative to plugin dir) to cache fixtures
"freeze_time": "2025-08-01 15:25:00",
- "skip_update": false
+ "skip_update": false,
+ "fill_check": "warn", # or "strict": underfilled big panels FAIL
+ "variants": [ # extra runs with config overlays and
+ { # their own golden dirs — e.g. an
+ "name": "adaptive", # opt-in adaptive mode tested beside
+ "config": {"layout_mode": "adaptive"}, # the classic default
+ "golden_dir": "test/golden-adaptive"
+ }
+ ]
}
Returns {} when no harness.json exists.
"""
diff --git a/test/test_harness_fill.py b/test/test_harness_fill.py
new file mode 100644
index 00000000..909a1556
--- /dev/null
+++ b/test/test_harness_fill.py
@@ -0,0 +1,98 @@
+"""Tests for the harness fill / scale-up check (src/plugin_system/testing/harness.py)."""
+
+from PIL import Image
+
+from src.plugin_system.testing.harness import (
+ RenderResult,
+ check_scale_up,
+ fill_metrics,
+)
+
+
+def _canvas(w, h):
+ return Image.new("RGB", (w, h), (0, 0, 0))
+
+
+def _with_block(w, h, bx, by, bw, bh, color=(255, 255, 255)):
+ img = _canvas(w, h)
+ img.paste(Image.new("RGB", (bw, bh), color), (bx, by))
+ return img
+
+
+def _result(w, h, image):
+ return RenderResult("p", w, h, "mode", image=image)
+
+
+class TestFillMetrics:
+ def test_full_white(self):
+ ex, ey, ink = fill_metrics(Image.new("RGB", (64, 32), (255, 255, 255)))
+ assert (ex, ey, ink) == (1.0, 1.0, 1.0)
+
+ def test_black_is_empty(self):
+ assert fill_metrics(_canvas(64, 32)) == (0.0, 0.0, 0.0)
+
+ def test_corner_dot(self):
+ ex, ey, ink = fill_metrics(_with_block(100, 100, 0, 0, 10, 10))
+ assert ex == 0.1 and ey == 0.1
+ assert ink == 0.01
+
+ def test_centered_half(self):
+ ex, ey, _ = fill_metrics(_with_block(100, 100, 25, 25, 50, 50))
+ assert ex == 0.5 and ey == 0.5
+
+ def test_dim_pixels_ignored(self):
+ img = _canvas(10, 10)
+ img.putpixel((5, 5), (10, 10, 10)) # below the lit threshold
+ assert fill_metrics(img) == (0.0, 0.0, 0.0)
+
+
+class TestCheckScaleUp:
+ def test_not_checked_below_2x(self):
+ # 128x64 vs design 128x32: only height is 2x -> checked on y only;
+ # 128x32 itself: not checked at all
+ r = _result(128, 32, _with_block(128, 32, 0, 0, 10, 10))
+ check_scale_up([r], design_size=(128, 32))
+ assert not r.fill_checked
+
+ def test_warn_mode_records_but_passes(self):
+ # tiny corner content on a 256x128 (2x both axes)
+ r = _result(256, 128, _with_block(256, 128, 0, 0, 20, 20))
+ check_scale_up([r], design_size=(128, 32), strict=False)
+ assert r.fill_checked
+ assert r.fill_ok is None # warn-only: not a failure
+ assert r.ok # still passes
+ assert r.fill_extent[0] < 0.5
+
+ def test_strict_mode_fails_underfill(self):
+ r = _result(256, 128, _with_block(256, 128, 0, 0, 20, 20))
+ check_scale_up([r], design_size=(128, 32), strict=True)
+ assert r.fill_ok is False
+ assert not r.ok
+
+ def test_well_filled_passes_strict(self):
+ r = _result(256, 128, _with_block(256, 128, 10, 10, 200, 100))
+ check_scale_up([r], design_size=(128, 32), strict=True)
+ assert r.fill_ok is True and r.ok
+
+ def test_axis_selection_wide_only(self):
+ # 256x32 vs design 128x32: width is 2x, height is not -> only the
+ # x-extent matters; content spanning full width but few rows passes
+ r = _result(256, 32, _with_block(256, 32, 0, 12, 250, 8))
+ check_scale_up([r], design_size=(128, 32), strict=True)
+ assert r.fill_ok is True
+
+ def test_axis_selection_wide_only_underfill(self):
+ r = _result(256, 32, _with_block(256, 32, 0, 12, 60, 8))
+ check_scale_up([r], design_size=(128, 32), strict=True)
+ assert r.fill_ok is False
+
+ def test_errored_render_skipped(self):
+ r = RenderResult("p", 256, 128, "m", error="boom")
+ check_scale_up([r], design_size=(128, 32), strict=True)
+ assert not r.fill_checked
+
+ def test_custom_design_size(self):
+ # 128x64 with design 64x32 IS 2x both axes
+ r = _result(128, 64, _with_block(128, 64, 0, 0, 10, 10))
+ check_scale_up([r], design_size=(64, 32), strict=False)
+ assert r.fill_checked