diff --git a/src/plugin_system/testing/harness.py b/src/plugin_system/testing/harness.py index 0e16f584..d46c11c7 100644 --- a/src/plugin_system/testing/harness.py +++ b/src/plugin_system/testing/harness.py @@ -27,7 +27,7 @@ from PIL import Image, ImageChops from src.logging_config import get_logger from .bounds_display_manager import BoundsCheckingDisplayManager -from .loading import load_config_defaults, load_manifest +from .loading import load_config_defaults, load_manifest, merge_config from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label logger = get_logger("[Plugin Harness]") @@ -294,7 +294,9 @@ def render_plugin_matrix( manifest = load_manifest(plugin_dir) # Start from config_schema.json defaults so the plugin behaves like a real # install; explicit caller config still wins over a schema default. - config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})} + config = merge_config( + merge_config({"enabled": True}, load_config_defaults(plugin_dir)), + config or {}) sizes = sizes or DEFAULT_TEST_SIZES results: List[RenderResult] = [] diff --git a/src/plugin_system/testing/loading.py b/src/plugin_system/testing/loading.py index 061e95a0..326b3e73 100644 --- a/src/plugin_system/testing/loading.py +++ b/src/plugin_system/testing/loading.py @@ -33,6 +33,45 @@ def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]: return json.load(f) +def _defaults_from_properties(properties: Dict[str, Any]) -> Dict[str, Any]: + """Defaults for one `properties` block, recursing into nested objects. + + An object property carries its defaults on its children, not on itself, so + reading only the top level dropped everything nested. That is most of the + fleet: config organised by league, or under customization/display_options, + lost 2,386 defaults across 37 of 44 plugins -- soccer-scoreboard alone lost + 539 of 565 -- and the harness rendered them with a config no install would + ever have. + """ + defaults: Dict[str, Any] = {} + for key, prop in (properties or {}).items(): + if not isinstance(prop, dict): + continue + if prop.get('type') == 'object' and isinstance(prop.get('properties'), dict): + nested = _defaults_from_properties(prop['properties']) + if nested: + defaults[key] = nested + elif 'default' in prop: + defaults[key] = prop['default'] + return defaults + + +def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: + """Deep-merge override onto base, without dropping sibling defaults. + + A shallow merge would let `-c '{"nhl": {"enabled": true}}'` replace the whole + nhl subtree and silently discard every other nhl default -- the same class of + bug this function exists to fix. + """ + merged = dict(base) + for key, value in (override or {}).items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = merge_config(merged[key], value) + else: + merged[key] = value + return merged + + def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]: """Extract default values from a plugin's config_schema.json (empty if none).""" schema_path = Path(plugin_dir) / 'config_schema.json' @@ -40,11 +79,7 @@ def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]: return {} with open(schema_path, 'r') as f: schema = json.load(f) - defaults: Dict[str, Any] = {} - for key, prop in schema.get('properties', {}).items(): - if isinstance(prop, dict) and 'default' in prop: - defaults[key] = prop['default'] - return defaults + return _defaults_from_properties(schema.get('properties', {})) def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]: