From 37f32cca3465b4bc9a8ccefc8158e4784f85e01c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:35:33 +0000 Subject: [PATCH 1/2] fix(soccer-scoreboard): let custom leagues (eng.2 etc.) save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding any league under "Add More Leagues" failed to save with HTTP 400, regardless of the league code entered — so the English Championship (eng.2) could not be configured at all. The web UI's array-table row editor stores every non-column property of a row in a hidden text input: an empty input comes back as null, and a list property comes back as the comma-separated string the user typed. custom_leagues declared those properties as strict "array"/"object"/"number", so a brand-new row submitted favorite_teams=null, exclude_teams=null, dynamic_duration.max_duration_seconds=null and dynamic_duration.modes=null, and the core's Draft-7 validation rejected the whole config. - Schema: the properties the row editor can leave empty now accept null, and the team lists also accept the comma-separated text the editor produces. - Manager: _normalize_custom_leagues() strips those nulls (so every downstream .get(key, default) falls back to its default), splits team strings back into lists, and trims/lowercases the league code. It runs from _load_custom_leagues(), i.e. on both startup and runtime config change. Previously a null "modes" also crashed supports_dynamic_duration(). - league_code pattern no longer rejects valid ESPN codes with underscores or prefixes longer than four letters (eng.league_cup, conmebol.libertadores). test_custom_league_config.py rebuilds the exact payload array-table.js submits for a new row and asserts it validates and normalizes correctly (32 checks). Also verified end-to-end with the core safety harness: feeding the raw web-UI payload as a config override registers and renders soccer_eng.2_live/recent/ upcoming. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FNF7jfQcu6dF63weiNZH93 --- plugins.json | 6 +- plugins/soccer-scoreboard/README.md | 27 +- plugins/soccer-scoreboard/config_schema.json | 48 +-- plugins/soccer-scoreboard/manager.py | 45 ++- plugins/soccer-scoreboard/manifest.json | 12 +- .../test_custom_league_config.py | 276 ++++++++++++++++++ 6 files changed, 381 insertions(+), 33 deletions(-) create mode 100644 plugins/soccer-scoreboard/test_custom_league_config.py diff --git a/plugins.json b/plugins.json index 271d394..6ec7d0d 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-07-30", + "last_updated": "2026-07-31", "plugins": [ { "id": "cricket-scoreboard", @@ -732,10 +732,10 @@ "plugin_path": "plugins/soccer-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-17", + "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "2.4.0" + "latest_version": "2.4.1" }, { "id": "static-image", diff --git a/plugins/soccer-scoreboard/README.md b/plugins/soccer-scoreboard/README.md index 856dc3a..4259310 100644 --- a/plugins/soccer-scoreboard/README.md +++ b/plugins/soccer-scoreboard/README.md @@ -182,7 +182,32 @@ The plugin supports the following soccer leagues: - **uefa.europa**: UEFA Europa League - **fifa.world**: FIFA World Cup -Additional leagues can be added via the **Custom Leagues** setting using any ESPN soccer league code (e.g. `mex.1`, `arg.1`, `bra.1`, `ned.1`). +### Adding another league + +Any other league ESPN covers can be added under **Add More Leagues** in the plugin +settings. Click **Add Item**, then fill in **both** a display name and the ESPN +league code — a row with a blank name will not save. + +Common codes: + +| Code | League | +| --- | --- | +| `eng.2` | English Championship | +| `eng.3` | English League One | +| `eng.fa` | FA Cup | +| `eng.league_cup` | EFL (Carabao) Cup | +| `mex.1` | Liga MX | +| `arg.1` | Argentine Primera División | +| `bra.1` | Brasileirão Série A | +| `ned.1` | Eredivisie | +| `sco.1` | Scottish Premiership | +| `tur.1` | Turkish Süper Lig | +| `bel.1` | Belgian Pro League | +| `conmebol.libertadores` | Copa Libertadores | + +Codes are lowercase and dot-separated, exactly as they appear in ESPN's own URLs +(`espn.com/soccer/scoreboard/_/league/eng.2`). Per-league favorites, durations, +and display modes live behind the ⚙ button on the league's row. ## FIFA World Cup diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index 2e37076..1f0e95d 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -4247,7 +4247,7 @@ "custom_leagues": { "type": "array", "title": "Add More Leagues", - "description": "Add any soccer league available on ESPN. Click 'Add Item' then enter a league code. Common codes: mex.1 (Liga MX), arg.1 (Argentina), bra.1 (Brazil), ned.1 (Eredivisie), sco.1 (Scottish Premiership), tur.1 (Turkish Süper Lig), bel.1 (Belgian Pro League)", + "description": "Add any soccer league available on ESPN. Click 'Add Item', then fill in BOTH a name and a league code. Common codes: eng.2 (English Championship), eng.3 (League One), eng.fa (FA Cup), eng.league_cup (EFL Cup), mex.1 (Liga MX), arg.1 (Argentina), bra.1 (Brazil), ned.1 (Eredivisie), sco.1 (Scottish Premiership), tur.1 (Turkish Süper Lig), bel.1 (Belgian Pro League)", "x-widget": "array-table", "x-columns": [ "name", @@ -4271,11 +4271,11 @@ "type": "string", "minLength": 1, "maxLength": 50, - "pattern": "^[a-z]{2,4}(\\.[a-z0-9]+)+$", - "description": "ESPN league code in dot-separated format (e.g., 'por.1', 'mex.1', 'arg.1', 'uefa.champions')" + "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)+$", + "description": "ESPN league code in dot-separated format (e.g., 'eng.2', 'mex.1', 'uefa.champions', 'eng.league_cup', 'conmebol.libertadores')" }, "priority": { - "type": "integer", + "type": ["integer", "null"], "default": 50, "minimum": 1, "maximum": 100, @@ -4287,17 +4287,17 @@ "description": "Whether this league is enabled" }, "favorite_teams": { - "type": "array", + "type": ["array", "string", "null"], "items": { "type": "string" }, "default": [], "uniqueItems": true, "maxItems": 20, - "description": "Favorite team abbreviations for this league" + "description": "Favorite team abbreviations for this league (comma-separated when typed into the row editor)" }, "exclude_teams": { - "type": "array", + "type": ["array", "string", "null"], "items": { "type": "string", "description": "Custom league team name or abbreviation" @@ -4305,7 +4305,7 @@ "uniqueItems": true, "maxItems": 20, "default": [], - "description": "Team abbreviations to always hide from live rotation and recent/final scores for this league (spoiler protection). Takes precedence over Favorite Teams and Show All Live.", + "description": "Team abbreviations to always hide from live rotation and recent/final scores for this league (spoiler protection). Takes precedence over Favorite Teams and Show All Live. Comma-separated when typed into the row editor.", "x-advanced": true }, "live_priority": { @@ -4368,7 +4368,7 @@ "additionalProperties": false }, "live_game_duration": { - "type": "integer", + "type": ["integer", "null"], "default": 20, "minimum": 10, "maximum": 120, @@ -4376,7 +4376,7 @@ "x-advanced": true }, "non_favorite_live_game_duration": { - "type": "integer", + "type": ["integer", "null"], "default": 0, "minimum": 0, "maximum": 120, @@ -4384,7 +4384,7 @@ "x-advanced": true }, "recent_game_duration": { - "type": "integer", + "type": ["integer", "null"], "default": 15, "minimum": 5, "maximum": 60, @@ -4392,7 +4392,7 @@ "x-advanced": true }, "upcoming_game_duration": { - "type": "integer", + "type": ["integer", "null"], "default": 15, "minimum": 5, "maximum": 60, @@ -4405,14 +4405,14 @@ "description": "Control how many games to show", "properties": { "recent_games_to_show": { - "type": "integer", + "type": ["integer", "null"], "default": 1, "minimum": 1, "maximum": 20, "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." }, "upcoming_games_to_show": { - "type": "integer", + "type": ["integer", "null"], "default": 10, "minimum": 1, "maximum": 20, @@ -4437,7 +4437,7 @@ "description": "Show all live games, not just favorites" }, "favorite_live_boost": { - "type": "integer", + "type": ["integer", "null"], "minimum": 1, "maximum": 5, "default": 2, @@ -4459,7 +4459,7 @@ "x-advanced": true }, "min_duration_seconds": { - "type": "number", + "type": ["number", "null"], "minimum": 10, "maximum": 300, "default": 30, @@ -4467,14 +4467,14 @@ "x-advanced": true }, "max_duration_seconds": { - "type": "number", + "type": ["number", "null"], "minimum": 60, "maximum": 600, "description": "Maximum total duration in seconds", "x-advanced": true }, "modes": { - "type": "object", + "type": ["object", "null"], "title": "Per-Mode Settings", "description": "Configure dynamic duration for specific modes", "properties": { @@ -4488,13 +4488,13 @@ "x-advanced": true }, "min_duration_seconds": { - "type": "number", + "type": ["number", "null"], "minimum": 10, "maximum": 300, "x-advanced": true }, "max_duration_seconds": { - "type": "number", + "type": ["number", "null"], "minimum": 60, "maximum": 600, "x-advanced": true @@ -4512,13 +4512,13 @@ "x-advanced": true }, "min_duration_seconds": { - "type": "number", + "type": ["number", "null"], "minimum": 10, "maximum": 300, "x-advanced": true }, "max_duration_seconds": { - "type": "number", + "type": ["number", "null"], "minimum": 60, "maximum": 600, "x-advanced": true @@ -4536,13 +4536,13 @@ "x-advanced": true }, "min_duration_seconds": { - "type": "number", + "type": ["number", "null"], "minimum": 10, "maximum": 300, "x-advanced": true }, "max_duration_seconds": { - "type": "number", + "type": ["number", "null"], "minimum": 60, "maximum": 600, "x-advanced": true diff --git a/plugins/soccer-scoreboard/manager.py b/plugins/soccer-scoreboard/manager.py index 72fae96..7cc06e7 100644 --- a/plugins/soccer-scoreboard/manager.py +++ b/plugins/soccer-scoreboard/manager.py @@ -454,10 +454,50 @@ def _build_custom_league_map(self) -> None: """Build O(1) lookup map from custom_leagues config, keyed by league_code.""" self._custom_league_map: Dict[str, Dict] = { cl['league_code']: cl - for cl in self.config.get('custom_leagues', []) - if cl.get('league_code') + for cl in (self.config.get('custom_leagues') or []) + if isinstance(cl, dict) and cl.get('league_code') } + def _normalize_custom_leagues(self) -> None: + """ + Clean up the custom_leagues config in place before anything reads it. + + The web UI's array-table editor keeps every non-column property in a + hidden text input, so anything it can't represent as text comes back as + null (empty input), and list properties come back as the comma-separated + string the user typed. Dropping the nulls lets every downstream + ``.get(key, default)`` fall back to its default, and splitting the + strings restores the list shape the managers expect. + """ + custom_leagues = self.config.get('custom_leagues') or [] + + def _strip_nulls(value: Any) -> Any: + if isinstance(value, dict): + return {k: _strip_nulls(v) for k, v in value.items() if v is not None} + return value + + normalized: List[Dict[str, Any]] = [] + for custom_league in custom_leagues: + if not isinstance(custom_league, dict): + self.logger.warning("Skipping malformed custom league entry: %r", custom_league) + continue + + cleaned = _strip_nulls(custom_league) + + # "ARS, CHE" (row editor) -> ["ARS", "CHE"] + for key in ('favorite_teams', 'exclude_teams'): + teams = cleaned.get(key) + if isinstance(teams, str): + cleaned[key] = [t.strip() for t in teams.split(',') if t.strip()] + + code = cleaned.get('league_code') + if isinstance(code, str): + cleaned['league_code'] = code.strip().lower() + + normalized.append(cleaned) + + self.config['custom_leagues'] = normalized + def _get_league_config(self, league_key: str, league_data: Optional[Dict] = None) -> Dict: """Get the config dict for a league, handling both predefined and custom leagues.""" if league_data is None: @@ -482,6 +522,7 @@ def _load_custom_leagues(self) -> None: 3. Updates league_enabled and league_live_priority dicts 4. Updates LEAGUE_NAMES for display purposes """ + self._normalize_custom_leagues() custom_leagues = self.config.get('custom_leagues', []) if not custom_leagues: diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 14a7ac5..411ba00 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.4.0", + "version": "2.4.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,12 @@ "soccer_upcoming" ], "versions": [ + { + "released": "2026-07-31", + "version": "2.4.1", + "notes": "Fix adding a custom league (e.g. the English Championship, eng.2) failing to save with HTTP 400. The web UI's row editor sends unset list/object properties as null and typed team lists as comma-separated text, which the strict array/object/number types in custom_leagues rejected, so the whole config save was blocked no matter which league code was entered. Those properties now accept null (and comma-separated text for team lists), and the plugin normalizes them back to defaults on load. The league_code pattern also no longer rejects valid ESPN codes with underscores or long prefixes (eng.league_cup, conmebol.libertadores).", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-29", "version": "2.4.0", @@ -136,7 +142,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-17", + "last_updated": "2026-07-31", "stars": 0, "downloads": 0, "verified": true, @@ -153,4 +159,4 @@ "compatible_versions": [ ">=2.0.0" ] -} \ No newline at end of file +} diff --git a/plugins/soccer-scoreboard/test_custom_league_config.py b/plugins/soccer-scoreboard/test_custom_league_config.py new file mode 100644 index 0000000..7225978 --- /dev/null +++ b/plugins/soccer-scoreboard/test_custom_league_config.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +""" +Regression test for saving a custom league (e.g. the English Championship, +'eng.2') from the web UI. + +Adding any custom league used to fail with HTTP 400 on save. The web UI's +array-table editor keeps every non-column property of a row in a hidden text +input, so an empty input comes back as null and a list property comes back as +the comma-separated string the user typed. The schema declared those properties +as strict "array"/"object"/"number", so the core's Draft-7 validation rejected +the whole config and blocked the save — regardless of which league code was +entered. + +This test rebuilds the exact payload that array-table.js produces for a new row +and asserts (a) it validates against config_schema.json and (b) the plugin +normalizes the nulls/strings back into the shapes the managers expect. + +Run: /bin/python plugins/soccer-scoreboard/test_custom_league_config.py +""" + +import json +import re +import sys +import types +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + + +def _stub_core_src(): + """Stub the core ``src.*`` modules the plugin imports at load time.""" + def mod(name, **attrs): + m = types.ModuleType(name) + for k, v in attrs.items(): + setattr(m, k, v) + sys.modules.setdefault(name, m) + return m + + mod("src") + mod("src.common") + mod("src.plugin_system") + mod("src.logo_downloader", LogoDownloader=object, download_missing_logo=lambda *a, **k: None) + mod("src.common.scroll_helper", ScrollHelper=object) + mod("src.plugin_system.base_plugin", BasePlugin=None, VegasDisplayMode=object) + mod("src.background_data_service", get_background_service=lambda *a, **k: None) + + +_stub_core_src() + +import logging # noqa: E402 + +from manager import SoccerScoreboardPlugin # noqa: E402 + +SCHEMA = json.loads((plugin_dir / "config_schema.json").read_text()) +CUSTOM = SCHEMA["properties"]["custom_leagues"] + +results = [] + + +def check(name, passed): + results.append((name, passed)) + print(f"{'PASS' if passed else 'FAIL'}: {name}") + + +# ============================================================================= +# Simulate the web UI's array-table widget +# +# createAdvancedCell() renders every non-column property as a hidden input whose +# value is String(default) (so [] -> "", undefined -> "", {} -> "[object +# Object]"), and getValue() reads it back through coerceValue(), which maps "" +# to null. Reproducing that here is what makes this a real regression test for +# the save path rather than a hand-written payload. +# ============================================================================= + + +def js_string(value): + """JavaScript String(value) for the values the widget stores.""" + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, list): + return ",".join(str(v) for v in value) + if isinstance(value, dict): + return "[object Object]" + return str(value) + + +def coerce_value(text, type_hint): + """array-table.js coerceValue().""" + if text == "": + return None + if type_hint == "integer": + return int(text) + if type_hint == "number": + return float(text) + if type_hint == "boolean": + return text in ("true", "1") + return text + + +def prop_type(prop_schema): + """The widget's `Array.isArray(type) ? type.find(t => t !== 'null')` rule.""" + declared = prop_schema.get("type") + if isinstance(declared, list): + return next((t for t in declared if t != "null"), "string") + return declared or "string" + + +def widget_row(typed_values): + """Build the row object array-table.js submits for one custom league.""" + columns = CUSTOM["x-columns"] + props = CUSTOM["items"]["properties"] + row = {} + + # Visible column cells: user-typed value, else the schema default. + for column in columns: + col_schema = props[column] + col_type = prop_type(col_schema) + default = col_schema.get("default", False if col_type == "boolean" else "") + row[column] = typed_values.get(column, default) + + # Hidden advanced cell: one flat input per non-column property, one level of + # nesting deep, round-tripped through String() and coerceValue(). + for name, prop_schema in props.items(): + if name in columns: + continue + declared = prop_type(prop_schema) + if declared == "object" and "properties" in prop_schema: + for sub_name, sub_schema in prop_schema["properties"].items(): + value = typed_values.get(name, {}).get(sub_name, sub_schema.get("default")) + row.setdefault(name, {})[sub_name] = coerce_value( + js_string(value), prop_type(sub_schema) + ) + else: + value = typed_values.get(name, prop_schema.get("default")) + row[name] = coerce_value(js_string(value), declared) + + return row + + +def schema_errors(config): + from jsonschema import Draft7Validator + + return [ + f"{'.'.join(str(p) for p in e.path)}: {e.message}" + for e in Draft7Validator(SCHEMA).iter_errors(config) + ] + + +try: + import jsonschema # noqa: F401 + HAVE_JSONSCHEMA = True +except ImportError: # pragma: no cover - CI installs it with the core + HAVE_JSONSCHEMA = False + print("SKIP: jsonschema not installed - schema validation cases skipped") + +# --- Case 1: a freshly added Championship row saves ------------------------ +championship = widget_row({"name": "Championship", "league_code": "eng.2"}) +if HAVE_JSONSCHEMA: + errors = schema_errors({"enabled": True, "custom_leagues": [championship]}) + check(f"new custom league row validates (errors: {errors})", errors == []) + + # --- Case 2: favorite/exclude teams typed in the row editor ------------ + with_teams = widget_row({ + "name": "Championship", + "league_code": "eng.2", + "favorite_teams": "IPS, NOR", + "exclude_teams": "SOU", + }) + errors = schema_errors({"enabled": True, "custom_leagues": [with_teams]}) + check(f"comma-separated team lists validate (errors: {errors})", errors == []) + + # --- Case 3: a cleared advanced number is not a save blocker ---------- + cleared = widget_row({"name": "Championship", "league_code": "eng.2"}) + cleared["live_game_duration"] = None + cleared["game_limits"]["recent_games_to_show"] = None + cleared["priority"] = None + errors = schema_errors({"enabled": True, "custom_leagues": [cleared]}) + check(f"cleared numeric fields validate (errors: {errors})", errors == []) + + # --- Case 4: name and league_code are still required ------------------ + errors = schema_errors({"enabled": True, "custom_leagues": [{"league_code": "eng.2"}]}) + check("missing name still rejected", any("name" in e for e in errors)) + +# --- Case 5: league_code pattern accepts real ESPN codes ------------------ +pattern = re.compile(CUSTOM["items"]["properties"]["league_code"]["pattern"]) +for code in ("eng.2", "eng.3", "eng.fa", "eng.league_cup", "mex.1", + "uefa.champions", "conmebol.libertadores", "usa.ncaa.m.1"): + check(f"league_code pattern accepts {code}", pattern.match(code) is not None) +for code in ("eng2", "ENG.2", "eng.2 ", "english championship", ""): + check(f"league_code pattern rejects {code!r}", pattern.match(code) is None) + + +# ============================================================================= +# Normalization: what the plugin does with the payload once it is saved +# ============================================================================= + + +def make_plugin(custom_leagues): + plugin = SoccerScoreboardPlugin.__new__(SoccerScoreboardPlugin) + plugin.logger = logging.getLogger("test-soccer-custom-leagues") + plugin.config = {"enabled": True, "custom_leagues": custom_leagues} + plugin.display_manager = None + plugin.cache_manager = None + plugin.plugin_manager = None + return plugin + + +plugin = make_plugin([widget_row({ + "name": "Championship", + "league_code": " ENG.2 ", + "favorite_teams": "IPS, NOR", + "exclude_teams": "SOU", +})]) +plugin._normalize_custom_leagues() +league = plugin.config["custom_leagues"][0] + +check("league_code trimmed and lowercased", league["league_code"] == "eng.2") +check("favorite_teams split into a list", league["favorite_teams"] == ["IPS", "NOR"]) +check("exclude_teams split into a list", league["exclude_teams"] == ["SOU"]) +check("null modes dropped so defaults apply", + league.get("dynamic_duration", {}).get("modes", {}) == {}) +check("null max_duration_seconds dropped", + "max_duration_seconds" not in league.get("dynamic_duration", {})) +check("defaults survive normalization", league["live_game_duration"] == 20) + +# Values the widget never nulls out must be untouched. +check("display_modes preserved", league["display_modes"]["live"] is True) +check("priority preserved", league["priority"] == 50) + +# A row with everything cleared falls back to defaults instead of crashing. +cleared_row = widget_row({"name": "Championship", "league_code": "eng.2"}) +cleared_row["favorite_teams"] = None +cleared_row["exclude_teams"] = None +cleared_row["display_modes"] = None +cleared_row["game_limits"] = None +cleared_row["filtering"] = None +cleared_row["dynamic_duration"] = None +plugin = make_plugin([cleared_row, "not-a-dict"]) +plugin._normalize_custom_leagues() +check("malformed entries dropped", len(plugin.config["custom_leagues"]) == 1) +cleared_league = plugin.config["custom_leagues"][0] +check("null containers dropped", + not any(k in cleared_league for k in + ("favorite_teams", "display_modes", "game_limits", "filtering", + "dynamic_duration"))) + +adapted = SoccerScoreboardPlugin._adapt_config_for_custom_league.__get__(plugin)( + cleared_league +)["soccer_eng.2_scoreboard"] +check("adapter falls back to empty favorites", adapted["favorite_teams"] == []) +check("adapter falls back to default durations", adapted["live_game_duration"] == 20) +check("adapter enables all display modes by default", + adapted["display_modes"]["soccer_eng.2_live"] is True) + +# supports_dynamic_duration() reads dynamic_duration/modes with .get() chains and +# used to hit AttributeError when either arrived as null. +plugin._league_registry = {"eng.2": {"is_custom": True, "enabled": True}} +plugin._custom_league_map = {"eng.2": cleared_league} +plugin.is_enabled = True +plugin._current_display_league = "eng.2" +plugin._current_display_mode_type = "live" +check("supports_dynamic_duration survives a cleared row", + plugin.supports_dynamic_duration() is False) +check("get_dynamic_duration_cap survives a cleared row", + plugin.get_dynamic_duration_cap() is None) + +print() +failed = [name for name, passed in results if not passed] +print(f"{len(results) - len(failed)}/{len(results)} passed") +if failed: + for name in failed: + print(f" FAILED: {name}") + sys.exit(1) From 564883b8f2b95e170499744957160bdfcedc4d7c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:41:03 +0000 Subject: [PATCH 2/2] test(soccer-scoreboard): drop __get__ call flagged by static analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codacy flagged one new critical ErrorProne issue: pylint reads `SoccerScoreboardPlugin._adapt_config_for_custom_league.__get__(plugin)(...)` as an unbound call missing two arguments (E1120). The manual descriptor binding was pointless — the test's plugin object is a real instance, so it can call the method directly. Also swaps the unused `import jsonschema` availability probe for importlib.util.find_spec and renames two locals that shadowed module-level names. No behavior change; still 32/32 checks passing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FNF7jfQcu6dF63weiNZH93 --- .../test_custom_league_config.py | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/plugins/soccer-scoreboard/test_custom_league_config.py b/plugins/soccer-scoreboard/test_custom_league_config.py index 7225978..af71dcf 100644 --- a/plugins/soccer-scoreboard/test_custom_league_config.py +++ b/plugins/soccer-scoreboard/test_custom_league_config.py @@ -18,6 +18,7 @@ Run: /bin/python plugins/soccer-scoreboard/test_custom_league_config.py """ +import importlib.util import json import re import sys @@ -149,11 +150,8 @@ def schema_errors(config): ] -try: - import jsonschema # noqa: F401 - HAVE_JSONSCHEMA = True -except ImportError: # pragma: no cover - CI installs it with the core - HAVE_JSONSCHEMA = False +HAVE_JSONSCHEMA = importlib.util.find_spec("jsonschema") is not None +if not HAVE_JSONSCHEMA: # pragma: no cover - CI installs it with the core print("SKIP: jsonschema not installed - schema validation cases skipped") # --- Case 1: a freshly added Championship row saves ------------------------ @@ -199,13 +197,13 @@ def schema_errors(config): def make_plugin(custom_leagues): - plugin = SoccerScoreboardPlugin.__new__(SoccerScoreboardPlugin) - plugin.logger = logging.getLogger("test-soccer-custom-leagues") - plugin.config = {"enabled": True, "custom_leagues": custom_leagues} - plugin.display_manager = None - plugin.cache_manager = None - plugin.plugin_manager = None - return plugin + instance = SoccerScoreboardPlugin.__new__(SoccerScoreboardPlugin) + instance.logger = logging.getLogger("test-soccer-custom-leagues") + instance.config = {"enabled": True, "custom_leagues": custom_leagues} + instance.display_manager = None + instance.cache_manager = None + instance.plugin_manager = None + return instance plugin = make_plugin([widget_row({ @@ -247,9 +245,7 @@ def make_plugin(custom_leagues): ("favorite_teams", "display_modes", "game_limits", "filtering", "dynamic_duration"))) -adapted = SoccerScoreboardPlugin._adapt_config_for_custom_league.__get__(plugin)( - cleared_league -)["soccer_eng.2_scoreboard"] +adapted = plugin._adapt_config_for_custom_league(cleared_league)["soccer_eng.2_scoreboard"] check("adapter falls back to empty favorites", adapted["favorite_teams"] == []) check("adapter falls back to default durations", adapted["live_game_duration"] == 20) check("adapter enables all display modes by default", @@ -268,9 +264,9 @@ def make_plugin(custom_leagues): plugin.get_dynamic_duration_cap() is None) print() -failed = [name for name, passed in results if not passed] +failed = [case for case, passed in results if not passed] print(f"{len(results) - len(failed)}/{len(results)} passed") if failed: - for name in failed: - print(f" FAILED: {name}") + for case in failed: + print(f" FAILED: {case}") sys.exit(1)