diff --git a/test/test_api_v3_partial_main_save.py b/test/test_api_v3_partial_main_save.py new file mode 100644 index 00000000..3a8315bc --- /dev/null +++ b/test/test_api_v3_partial_main_save.py @@ -0,0 +1,277 @@ +"""POST /config/main: a partial JSON body changes only what it sends. + +The settings forms post every field, and a browser leaves an unchecked box out, +so for a form a missing checkbox means False. JSON API clients send only what +they change. Treating their missing keys as unchecked meant: + +- the MQTT bridge's Home Assistant brightness slider (``{"brightness": N}``) + turned off disable_hardware_pulsing, inverse_colors, show_refresh_rate and + use_short_date_format on every change; +- the documented timezone/location update turned off web_display_autostart and + weekly automatic updates. + +The v3 forms post JSON as well (htmx json-enc), so they mark themselves with a +hidden ``__form_section`` input; these tests pin both halves of that contract. + +Also here: the Vegas cycle-time fields no longer land in display_durations +(and a blank one no longer 400s the Display save), the Raw JSON editor starts +auto-update setup like the General form does, and both schedule POSTs accept +the per-day shape their GETs return. +""" + +import copy +import json +import re +from pathlib import Path + +import pytest + +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401 + +REPO = Path(__file__).resolve().parent.parent +PARTIALS = REPO / 'web_interface' / 'templates' / 'v3' / 'partials' + +STORED = { + 'web_display_autostart': True, + 'auto_update': {'enabled': True}, + 'timezone': 'America/Chicago', + 'plugin_system': {'auto_discover': True, 'auto_load_enabled': True, + 'development_mode': True}, + 'display': { + 'hardware': {'rows': 32, 'cols': 64, 'chain_length': 2, 'brightness': 90, + 'disable_hardware_pulsing': True, 'inverse_colors': True, + 'show_refresh_rate': True}, + 'runtime': {'gpio_slowdown': 4}, + 'use_short_date_format': True, + 'double_sided': {'enabled': True, 'copies': 2, 'axis': 'horizontal'}, + 'vegas_scroll': {'enabled': True, 'auto_trim': True, + 'dynamic_duration_enabled': True, + 'continuous_scroll': True, 'smooth_scroll': True}, + }, +} + + +@pytest.fixture +def saved(api_v3_module, monkeypatch): + captured = {} + api_v3_module.api_v3.config_manager.load_config.side_effect = \ + lambda *a, **k: copy.deepcopy(STORED) + + def fake_save(_manager, config, **_kwargs): + captured['config'] = config + return True, '' + + monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save) + from web_interface import auto_update + monkeypatch.setattr(auto_update, 'start_setup_if_needed', lambda *a, **k: None) + return captured + + +def _post_json(client, body): + return client.post('/api/v3/config/main', data=json.dumps(body), + content_type='application/json') + + +class TestJsonPartialSaves: + def test_mqtt_bridge_brightness_changes_only_brightness(self, api_v3_client, saved): + # integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py set_brightness + resp = _post_json(api_v3_client, {'brightness': 40}) + assert resp.status_code == 200, resp.get_json() + display = saved['config']['display'] + assert display['hardware']['brightness'] == 40 + assert display['hardware']['disable_hardware_pulsing'] is True + assert display['hardware']['inverse_colors'] is True + assert display['hardware']['show_refresh_rate'] is True + assert display['use_short_date_format'] is True + + def test_timezone_only_keeps_autostart_and_auto_update(self, api_v3_client, saved): + resp = _post_json(api_v3_client, {'timezone': 'UTC'}) + assert resp.status_code == 200, resp.get_json() + config = saved['config'] + assert config['timezone'] == 'UTC' + assert config['web_display_autostart'] is True + assert config['auto_update'] == {'enabled': True} + + def test_location_only_keeps_plugin_system_toggles(self, api_v3_client, saved): + resp = _post_json(api_v3_client, {'city': 'Paris', 'plugins_directory': 'plugin-repos'}) + assert resp.status_code == 200, resp.get_json() + assert saved['config']['plugin_system'] == { + 'auto_discover': True, 'auto_load_enabled': True, + 'development_mode': True, 'plugins_directory': 'plugin-repos'} + + def test_vegas_speed_only_keeps_vegas_toggles(self, api_v3_client, saved): + resp = _post_json(api_v3_client, {'vegas_scroll_speed': 80}) + assert resp.status_code == 200, resp.get_json() + vegas = saved['config']['display']['vegas_scroll'] + assert vegas['scroll_speed'] == 80 + for key in ('enabled', 'auto_trim', 'dynamic_duration_enabled', + 'continuous_scroll', 'smooth_scroll'): + assert vegas[key] is True, key + + def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved): + resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'}) + assert resp.status_code == 200, resp.get_json() + assert saved['config']['display']['double_sided']['enabled'] is True + + def test_json_can_still_turn_a_checkbox_off(self, api_v3_client, saved): + resp = _post_json(api_v3_client, {'inverse_colors': False, 'auto_update_enabled': False}) + assert resp.status_code == 200, resp.get_json() + assert saved['config']['display']['hardware']['inverse_colors'] is False + assert saved['config']['display']['hardware']['disable_hardware_pulsing'] is True + assert saved['config']['auto_update'] == {'enabled': False} + + def test_json_with_charset_is_still_json(self, api_v3_client, saved): + resp = api_v3_client.post('/api/v3/config/main', data=json.dumps({'brightness': 41}), + content_type='application/json; charset=utf-8') + assert resp.status_code == 200, resp.get_json() + assert saved['config']['display']['hardware']['brightness'] == 41 + assert saved['config']['display']['hardware']['inverse_colors'] is True + + def test_a_non_object_body_is_refused(self, api_v3_client, saved): + assert _post_json(api_v3_client, [1, 2]).status_code == 400 + + +class TestFormSavesStillUncheck: + """Unchecking a box in the UI must still save false.""" + + def test_marked_json_form_post_unchecks_missing_display_boxes(self, api_v3_client, saved): + resp = _post_json(api_v3_client, {'__form_section': 'display', 'brightness': '40', + 'vegas_scroll_speed': '50'}) + assert resp.status_code == 200, resp.get_json() + display = saved['config']['display'] + for key in ('disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate'): + assert display['hardware'][key] is False, key + assert display['use_short_date_format'] is False + assert display['vegas_scroll']['enabled'] is False + assert '__form_section' not in saved['config'] + + def test_marked_json_form_post_keeps_checked_boxes(self, api_v3_client, saved): + resp = _post_json(api_v3_client, {'__form_section': 'display', 'brightness': '40', + 'inverse_colors': 'on'}) + assert resp.status_code == 200, resp.get_json() + assert saved['config']['display']['hardware']['inverse_colors'] is True + assert saved['config']['display']['hardware']['show_refresh_rate'] is False + + def test_marked_general_form_unchecks_autostart_and_auto_update(self, api_v3_client, saved): + resp = _post_json(api_v3_client, {'__form_section': 'general', 'timezone': 'UTC'}) + assert resp.status_code == 200, resp.get_json() + assert saved['config']['web_display_autostart'] is False + assert saved['config']['auto_update'] == {'enabled': False} + + def test_form_encoded_post_unchecks_missing_boxes(self, api_v3_client, saved): + resp = api_v3_client.post('/api/v3/config/main', data={'brightness': '40'}, + content_type='application/x-www-form-urlencoded') + assert resp.status_code == 200, resp.get_json() + assert saved['config']['display']['hardware']['inverse_colors'] is False + + @pytest.mark.parametrize('partial', ['general.html', 'display.html', 'durations.html']) + def test_every_config_main_form_carries_the_marker(self, partial): + html = (PARTIALS / partial).read_text(encoding='utf-8') + form = re.search(r'