diff --git a/test/test_web_api.py b/test/test_web_api.py index e8f57bea..d65b11ff 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -146,6 +146,11 @@ class TestConfigAPI: def test_save_double_sided_settings(self, client, mock_config_manager): """Double-sided form fields are persisted under display.double_sided.""" + # 2 copies on the vertical axis needs parallel to be a multiple of 2. + mock_config_manager.load_config.return_value['display']['hardware'] = { + 'chain_length': 2, 'parallel': 2, + } + response = client.post( '/api/v3/config/main', data={ @@ -175,6 +180,91 @@ class TestConfigAPI: assert ds['enabled'] is False assert ds['copies'] == 4 + def test_save_double_sided_disabled_skips_divisibility_check(self, client, mock_config_manager): + """A copies/chain_length mismatch must not block saves while disabled. + + The Display form posts copies/axis on every save, so validating them + with the feature off locked users out of every other display setting. + """ + mock_config_manager.load_config.return_value['display']['hardware'] = { + 'chain_length': 3, 'parallel': 1, + } + + response = client.post( + '/api/v3/config/main', + data={ + 'double_sided_copies': '2', + 'double_sided_axis': 'horizontal', + 'brightness': '75', + }, + content_type='application/x-www-form-urlencoded', + ) + + assert response.status_code == 200 + ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided'] + assert ds['enabled'] is False + assert ds['copies'] == 2 + + def test_save_double_sided_enabled_enforces_divisibility(self, client, mock_config_manager): + """The same mismatch is still rejected once the feature is turned on.""" + mock_config_manager.load_config.return_value['display']['hardware'] = { + 'chain_length': 3, 'parallel': 1, + } + + response = client.post( + '/api/v3/config/main', + data={ + 'double_sided_enabled': 'true', + 'double_sided_copies': '2', + 'double_sided_axis': 'horizontal', + }, + content_type='application/x-www-form-urlencoded', + ) + + assert response.status_code == 400 + assert 'chain length' in response.get_json()['message'] + mock_config_manager.save_config_atomic.assert_not_called() + + def test_save_double_sided_vertical_checks_parallel(self, client, mock_config_manager): + """The vertical axis is checked against parallel, not chain_length.""" + mock_config_manager.load_config.return_value['display']['hardware'] = { + 'chain_length': 2, 'parallel': 3, + } + + response = client.post( + '/api/v3/config/main', + data={ + 'double_sided_enabled': 'true', + 'double_sided_copies': '2', + 'double_sided_axis': 'vertical', + }, + content_type='application/x-www-form-urlencoded', + ) + + # chain_length 2 would divide evenly — only parallel 3 rejects this. + assert response.status_code == 400 + assert 'parallel' in response.get_json()['message'] + mock_config_manager.save_config_atomic.assert_not_called() + + def test_save_double_sided_disabled_ignores_bad_values(self, client, mock_config_manager): + """While disabled, unusable copies/axis are dropped rather than rejected.""" + mock_config_manager.load_config.return_value['display']['double_sided'] = { + 'enabled': True, 'copies': 2, 'axis': 'horizontal', + } + + response = client.post( + '/api/v3/config/main', + data={'double_sided_copies': 'abc', 'double_sided_axis': 'diagonal'}, + content_type='application/x-www-form-urlencoded', + ) + + assert response.status_code == 200 + ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided'] + assert ds['enabled'] is False + # Stored values left untouched rather than overwritten with junk. + assert ds['copies'] == 2 + assert ds['axis'] == 'horizontal' + def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager): """copies < 2 is rejected with a 400 before any save.""" response = client.post( diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 41631ef1..f92c7852 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -13,7 +13,7 @@ import uuid import logging from datetime import datetime from pathlib import Path -from typing import Dict, Any +from typing import Dict, Any, Optional from urllib.parse import urlparse, urlunparse logger = logging.getLogger(__name__) @@ -864,16 +864,15 @@ def save_main_config(): ds_config = current_config['display']['double_sided'] # Enabled checkbox: omitted from the form when unchecked. - ds_config['enabled'] = _coerce_to_bool(data.get('double_sided_enabled')) + # The Display form posts copies/axis on every save regardless of this + # checkbox, so when the feature is off we accept the values without + # rejecting the whole save — otherwise a stale copies/chain_length + # mismatch locks the user out of every other display setting. + enabled = _coerce_to_bool(data.get('double_sided_enabled')) + ds_config['enabled'] = enabled - if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None): - try: - copies = int(data['double_sided_copies']) - except (ValueError, TypeError): - return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400 - if not (2 <= copies <= 8): - return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400 - # Validate divisibility against the relevant hardware dimension. + def _copies_fits_hardware(copies: int) -> Optional[str]: + """Error message if copies doesn't divide the panel evenly, else None.""" # Use axis from this request if provided, else from stored config. hw = current_config.get('display', {}).get('hardware', {}) effective_axis = (data.get('double_sided_axis') @@ -881,18 +880,41 @@ def save_main_config(): if effective_axis == 'horizontal': chain_length = int(hw.get('chain_length', 2) or 2) if chain_length % copies != 0: - return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"}), 400 + return f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly" elif effective_axis == 'vertical': parallel = int(hw.get('parallel', 1) or 1) if parallel % copies != 0: - return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"}), 400 - ds_config['copies'] = copies + return f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly" + return None + + if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None): + copies = None + try: + copies = int(data['double_sided_copies']) + except (ValueError, TypeError): + if enabled: + return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400 + if copies is not None and not (2 <= copies <= 8): + if enabled: + return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400 + # Disabled: leave the stored value alone rather than writing junk. + copies = None + if copies is not None: + # Divisibility is a hardware-relational check — only meaningful + # when the feature is actually on. + if enabled: + fit_error = _copies_fits_hardware(copies) + if fit_error: + return jsonify({'status': 'error', 'message': fit_error}), 400 + ds_config['copies'] = copies if 'double_sided_axis' in data: axis = data['double_sided_axis'] if axis not in ('horizontal', 'vertical'): - return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400 - ds_config['axis'] = axis + if enabled: + return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400 + else: + ds_config['axis'] = axis # Handle Vegas scroll mode settings vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width', diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index b577010f..80cc0709 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -30,7 +30,7 @@ hx-ext="json-enc" hx-headers='{"Content-Type": "application/json"}' hx-swap="none" - hx-on:htmx:after-request="showNotification(event.detail.xhr.responseJSON?.message || 'Display settings saved', event.detail.xhr.responseJSON?.status || 'success')" + hx-on:htmx:after-request="showDisplaySaveResult(event.detail.xhr)" class="space-y-6" novalidate onsubmit="fixInvalidNumberInputs(this); return true;"> @@ -475,22 +475,26 @@
Show the same content on every panel in the chain — e.g. two 64×32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.
-