Files
LEDMatrix/test/test_display_geometry.py
T
ChuckandClaude Opus 5 c693278d33 fix(display): size the preview after orientation and pixel mappers
display_geometry.physical_size claimed to give DisplayManager's answer but
only computed cols*chain x rows*parallel. RGBMatrix.width/height are measured
after the library's pixel mappers, so a Rotate:90 / orientation 90 chain
previewed 128x32 for a 32x128 panel and a U-mapper chain of four 256x32 for
128x64.

Model the built-in mappers' size effect as the pinned lib/pixel-mapper.cc
does (Rotate, U-mapper, V-mapper, StackToRow, Remap; Mirror and unknown
names leave it alone), and move the orientation composition here so
DisplayManager and the preview share it. The module docstring no longer
claims the sync handshake uses it; that imports only DEFAULT_CHAIN_LENGTH.

Audit finding F18.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 08:59:47 -04:00

196 lines
8.1 KiB
Python

"""The web preview must be the size DisplayManager actually renders at.
Before src/display_geometry.py, the preview endpoints computed
``cols * chain_length`` by ``rows * parallel`` themselves, ignored
``display.double_sided`` (so a double-sided panel previewed two screens side
by side), and the ``chain_length`` fallback was 2 in DisplayManager but 1 in
the Starlark magnify default and the sync handshake.
"""
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
logical_size, physical_size,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
def _config(hardware=None, double_sided=None):
display = {'hardware': hardware or {}}
if double_sided is not None:
display['double_sided'] = double_sided
return {'display': display}
def test_defaults_match_the_config_template():
template = json.loads(
(REPO_ROOT / 'config' / 'config.template.json').read_text(encoding='utf-8'))
hw = template['display']['hardware']
assert (DEFAULT_ROWS, DEFAULT_COLS, DEFAULT_CHAIN_LENGTH, DEFAULT_PARALLEL) == (
hw['rows'], hw['cols'], hw['chain_length'], hw['parallel'])
def test_missing_hardware_uses_the_template_defaults():
assert physical_size({}) == (128, 32)
assert logical_size({}) == (128, 32)
def test_parallel_multiplies_height():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 2})
assert physical_size(cfg) == (128, 64)
def test_double_sided_horizontal_previews_one_screen():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 4, 'parallel': 1},
{'enabled': True, 'copies': 2, 'axis': 'horizontal'})
assert physical_size(cfg) == (256, 32)
assert logical_size(cfg) == (128, 32)
def test_double_sided_vertical_splits_parallel():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 2},
{'enabled': True, 'copies': 2, 'axis': 'vertical'})
assert logical_size(cfg) == (128, 32)
def test_double_sided_that_does_not_divide_falls_back_to_physical():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1},
{'enabled': True, 'copies': 3, 'axis': 'horizontal'})
assert logical_size(cfg) == (128, 32)
def test_disabled_double_sided_is_ignored():
cfg = _config({'chain_length': 4}, {'enabled': False, 'copies': 2})
assert logical_size(cfg) == (256, 32)
@pytest.mark.parametrize('display', ['oops', ['a'], 1, {'hardware': 'oops'},
{'hardware': ['a']}])
def test_non_mapping_display_config_uses_the_defaults(display):
assert physical_size({'display': display}) == (128, 32)
assert logical_size({'display': display}) == (128, 32)
@pytest.mark.parametrize('key', ['rows', 'cols', 'chain_length', 'parallel'])
def test_infinite_hardware_value_raises_value_error(key):
# json.loads accepts Infinity; int(inf) raises OverflowError, which the
# Starlark magnify default and the preview stream did not catch (HTTP 500).
cfg = _config({key: json.loads('Infinity')})
with pytest.raises(ValueError):
physical_size(cfg)
with pytest.raises(ValueError):
logical_size(cfg)
@pytest.fixture
def display_client(monkeypatch):
from web_interface.blueprints.api_v3 import api_v3
config_manager = MagicMock()
monkeypatch.setattr(api_v3, 'config_manager', config_manager, raising=False)
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(api_v3, url_prefix='/api/v3')
with app.test_client() as client:
yield client, config_manager
def test_display_current_reports_the_logical_size(display_client):
client, config_manager = display_client
config_manager.load_config.return_value = _config(
{'rows': 32, 'cols': 64, 'chain_length': 4, 'parallel': 2},
{'enabled': True, 'copies': 2, 'axis': 'horizontal'})
data = client.get('/api/v3/display/current').get_json()['data']
assert (data['width'], data['height']) == (128, 64)
def test_display_current_defaults_chain_length_like_display_manager(display_client):
client, config_manager = display_client
config_manager.load_config.return_value = _config({'rows': 32, 'cols': 64})
data = client.get('/api/v3/display/current').get_json()['data']
assert (data['width'], data['height']) == (64 * DEFAULT_CHAIN_LENGTH, 32)
def test_display_current_falls_back_to_the_shared_default(display_client):
client, config_manager = display_client
config_manager.load_config.side_effect = ValueError('unreadable')
data = client.get('/api/v3/display/current').get_json()['data']
assert (data['width'], data['height']) == logical_size({}) == (128, 32)
def test_preview_callers_do_not_rederive_the_size():
"""Every preview/size caller goes through display_geometry, so none of
them can drift back to a private chain_length default."""
for rel in ('web_interface/app.py',
'web_interface/blueprints/api_v3/display.py',
'web_interface/blueprints/api_v3/__init__.py',
'src/common/sync_manager.py',
'src/display_manager.py'):
source = (REPO_ROOT / rel).read_text(encoding='utf-8')
assert "get('chain_length', 1)" not in source, rel
assert 'get("chain_length", 1)' not in source, rel
assert 'cols * chain_length' not in source, rel
# --- Pixel mappers ----------------------------------------------------------
# RGBMatrix.width/height are measured after the library's pixel mappers
# (lib/pixel-mapper.cc), so the preview has to apply them too. This used to
# report 128x32 for a Rotate:90 chain the panel drew at 32x128.
@pytest.mark.parametrize('hardware,expected', [
({'pixel_mapper_config': 'Rotate:90'}, (32, 128)),
({'pixel_mapper_config': 'Rotate:270'}, (32, 128)),
({'pixel_mapper_config': 'Rotate:-90'}, (32, 128)),
({'pixel_mapper_config': 'Rotate:180'}, (128, 32)),
({'orientation': '90'}, (32, 128)),
({'orientation': '270'}, (32, 128)),
({'orientation': '180'}, (128, 32)),
# Two quarter turns cancel out.
({'pixel_mapper_config': 'Rotate:90', 'orientation': '270'}, (128, 32)),
# U-mapper folds a chain of 4 into two rows: (256 / 64) * 32 by 2 * 32.
({'chain_length': 4, 'pixel_mapper_config': 'U-mapper'}, (128, 64)),
({'chain_length': 4, 'pixel_mapper_config': 'u-mapper;Rotate:90'}, (64, 128)),
# U-mapper needs an even chain of at least 2, or the library skips it.
({'chain_length': 3, 'pixel_mapper_config': 'U-mapper'}, (192, 32)),
({'cols': 32, 'chain_length': 4, 'pixel_mapper_config': 'V-mapper'}, (32, 128)),
({'chain_length': 1, 'parallel': 2, 'pixel_mapper_config': 'StackToRow:Z'}, (128, 32)),
({'pixel_mapper_config': 'Remap:64,64|0,0n|0,32n'}, (64, 64)),
# A Remap panel entirely outside the visible area: the library skips it.
({'pixel_mapper_config': 'Remap:64,64|0,0n|0,99n'}, (128, 32)),
({'pixel_mapper_config': 'Mirror:H'}, (128, 32)),
# Unknown or unusable mappers are skipped by the library.
({'pixel_mapper_config': 'Bogus;Rotate:45;Rotate:ninety'}, (128, 32)),
])
def test_pixel_mappers_change_the_size_as_the_library_does(hardware, expected):
hw = {'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1}
hw.update(hardware)
assert physical_size(_config(hw)) == expected
assert logical_size(_config(hw)) == expected
def test_double_sided_splits_the_mapped_size():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1, 'orientation': '90'},
{'enabled': True, 'copies': 2, 'axis': 'vertical'})
assert logical_size(cfg) == (32, 64)
def test_display_manager_composes_the_mapper_config_it_sizes_from():
from src.display_geometry import compose_pixel_mapper_config
assert compose_pixel_mapper_config({}) == ''
assert compose_pixel_mapper_config({'orientation': '90'}) == 'Rotate:90'
assert compose_pixel_mapper_config(
{'pixel_mapper_config': ' U-mapper ', 'orientation': '180'}) == 'U-mapper;Rotate:180'
assert compose_pixel_mapper_config({'orientation': 'sideways'}) == ''