mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
fix: handle dotted schema keys in plugin settings save (#295)
* fix: handle dotted schema keys in plugin settings save (issue #254) The soccer plugin uses dotted keys like "eng.1" for league identifiers. PR #260 fixed backend helpers but the JS frontend still corrupted these keys by naively splitting on dots. This fixes both the JS and remaining Python code paths: - JS getSchemaProperty(): greedy longest-match for dotted property names - JS dotToNested(): schema-aware key grouping to preserve "eng.1" as one key - Python fix_array_structures(): remove broken prefix re-navigation in recursion - Python ensure_array_defaults(): same prefix navigation fix Closes #254 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review findings for dotted-key handling - ensure_array_defaults: replace None nodes with {} so recursion proceeds into nested objects (was skipping when key existed as None) - dotToNested: add tail-matching that checks the full remaining dotted tail against the current schema level before greedy intermediate matching, preventing leaf dotted keys from being split - syncFormToJson: replace naive key.split('.') reconstruction with dotToNested(flatConfig, schema) and schema-aware getSchemaProperty() so the JSON tab save path produces the same correct nesting as the form submit path - Add regression tests for dotted-key array normalization and None array default replacement Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address second round of review findings - Tests: replace conditional `if response.status_code == 200` guards with unconditional `assert response.status_code == 200` so failures are not silently swallowed - dotToNested: guard finalKey write with `if (i < parts.length)` to prevent empty-string key pollution when tail-matching consumed all parts - Extract normalizeFormDataForConfig() helper from handlePluginConfigSubmit and call it from both handlePluginConfigSubmit and syncFormToJson so the JSON tab sync uses the same robust FormData processing (including _data JSON inputs, bracket-notation checkboxes, array-of-objects, file-upload widgets, checkbox DOM detection, and unchecked boolean handling via collectBooleanFields) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -583,3 +583,130 @@ class TestAPIErrorHandling:
|
||||
response = client.get('/api/v3/display/on-demand/start')
|
||||
|
||||
assert response.status_code in [200, 405] # Depends on implementation
|
||||
|
||||
|
||||
class TestDottedKeyNormalization:
|
||||
"""Regression tests for fix_array_structures / ensure_array_defaults with dotted schema keys."""
|
||||
|
||||
def test_save_plugin_config_dotted_key_arrays(self, client, mock_config_manager):
|
||||
"""Nested dotted-key objects with numeric-keyed dicts are converted to arrays."""
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
api_v3.config_manager = mock_config_manager
|
||||
mock_config_manager.load_config.return_value = {}
|
||||
|
||||
schema_mgr = MagicMock()
|
||||
schema = {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'leagues': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'eng.1': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'enabled': {'type': 'boolean', 'default': True},
|
||||
'favorite_teams': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'default': [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema_mgr.load_schema.return_value = schema
|
||||
schema_mgr.generate_default_config.return_value = {
|
||||
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
|
||||
}
|
||||
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||
schema_mgr.validate_config_against_schema.return_value = []
|
||||
api_v3.schema_manager = schema_mgr
|
||||
|
||||
request_data = {
|
||||
'plugin_id': 'soccer-scoreboard',
|
||||
'config': {
|
||||
'leagues': {
|
||||
'eng.1': {
|
||||
'enabled': True,
|
||||
'favorite_teams': ['Arsenal', 'Chelsea'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
'/api/v3/plugins/config',
|
||||
data=json.dumps(request_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
|
||||
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||
soccer_cfg = saved.get('soccer-scoreboard', {})
|
||||
leagues = soccer_cfg.get('leagues', {})
|
||||
assert 'eng.1' in leagues, f"Expected 'eng.1' key, got: {list(leagues.keys())}"
|
||||
assert isinstance(leagues['eng.1'].get('favorite_teams'), list)
|
||||
assert leagues['eng.1']['favorite_teams'] == ['Arsenal', 'Chelsea']
|
||||
|
||||
def test_save_plugin_config_none_array_gets_default(self, client, mock_config_manager):
|
||||
"""None array fields under dotted-key parents are replaced with defaults."""
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
api_v3.config_manager = mock_config_manager
|
||||
mock_config_manager.load_config.return_value = {}
|
||||
|
||||
schema_mgr = MagicMock()
|
||||
schema = {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'leagues': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'eng.1': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'favorite_teams': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'default': [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema_mgr.load_schema.return_value = schema
|
||||
schema_mgr.generate_default_config.return_value = {
|
||||
'leagues': {'eng.1': {'favorite_teams': []}},
|
||||
}
|
||||
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||
schema_mgr.validate_config_against_schema.return_value = []
|
||||
api_v3.schema_manager = schema_mgr
|
||||
|
||||
request_data = {
|
||||
'plugin_id': 'soccer-scoreboard',
|
||||
'config': {
|
||||
'leagues': {
|
||||
'eng.1': {
|
||||
'favorite_teams': None,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
'/api/v3/plugins/config',
|
||||
data=json.dumps(request_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
|
||||
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||
soccer_cfg = saved.get('soccer-scoreboard', {})
|
||||
teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams')
|
||||
assert isinstance(teams, list), f"Expected list, got: {type(teams)}"
|
||||
assert teams == [], f"Expected empty default list, got: {teams}"
|
||||
|
||||
Reference in New Issue
Block a user