mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
feat(harness): scale-up fill check, config variants, multi-size dev gallery
Quality gates for adaptive layout: - fill_metrics()/check_scale_up() in the safety harness: overflow catches content too big for a panel, but nothing caught content that stays tiny on panels >= 2x the plugin's declared design size. The check measures lit-content extents and warns (or fails, when a plugin opts into "fill_check": "strict" in test/harness.json) below 50% coverage on the doubled axis. Warn-only by default so no existing plugin breaks. - harness.json "variants": extra runs with config overlays and their own golden dirs, so an opt-in mode (e.g. layout_mode: adaptive) is golden- tested beside the classic default. check_plugin.py loops base + variants and labels variant results mode@name. - Dev preview server: GET /api/sizes (harness size sample), POST /api/render-matrix (render at up to 12 sizes in one call), size-preset dropdown, and an "All Sizes" side-by-side gallery in the preview UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+56
-20
@@ -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}")
|
||||
|
||||
+119
-55
@@ -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})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -209,6 +209,11 @@
|
||||
onchange="onConfigChange()">
|
||||
<span class="text-xs ml-2" style="color: var(--text-secondary);">px</span>
|
||||
</div>
|
||||
<select id="sizePreset" onchange="applySizePreset()"
|
||||
class="w-full mt-2 px-2 py-1.5 rounded text-xs"
|
||||
style="background: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
||||
<option value="">Preset sizes…</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Config form -->
|
||||
@@ -242,13 +247,18 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Render button -->
|
||||
<!-- Render buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="renderPlugin()" id="renderBtn"
|
||||
class="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style="background: var(--accent);">
|
||||
Render
|
||||
</button>
|
||||
<button onclick="renderAllSizes()" id="renderAllBtn" title="Render at every harness test size"
|
||||
class="px-4 py-2.5 rounded-lg text-sm font-medium"
|
||||
style="background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color);">
|
||||
All Sizes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -311,6 +321,15 @@
|
||||
<div id="messagesPanel" class="panel p-3 hidden">
|
||||
<div id="messagesList" class="text-xs font-mono space-y-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-size gallery -->
|
||||
<div id="galleryPanel" class="panel p-4 hidden">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs font-medium" style="color: var(--text-secondary);">All Sizes</span>
|
||||
<span class="text-xs" style="color: var(--text-secondary);" id="galleryStatus"></span>
|
||||
</div>
|
||||
<div id="galleryGrid" class="flex flex-wrap gap-4 items-start"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user