diff --git a/.github/workflows/test_validate_plugins.py b/.github/workflows/test_validate_plugins.py index cb27248..805e9ad 100644 --- a/.github/workflows/test_validate_plugins.py +++ b/.github/workflows/test_validate_plugins.py @@ -307,6 +307,73 @@ Configure the update interval in plugin settings. self.assertTrue(any("## Settings" in error for error in self.validate_readme(without_settings))) +class SettingTypeTests(unittest.TestCase): + TRANSLATIONS = {"settings": {"value": {"label": "Value"}}} + + def validate_setting(self, setting: dict, plugin_api: object = 6) -> list[str]: + validator = validate_plugins.Validator(Path("/repo")) + validator.validate_settings( + Path("/repo/example/plugin.toml"), + self.TRANSLATIONS, + [{"key": "value", "label_key": "settings.value.label", **setting}], + "setting", + plugin_api, + ) + return validator.errors + + def test_setting_type_catalog_matches_shell_schema(self) -> None: + self.assertEqual( + validate_plugins.SETTING_TYPES, + { + "string", + "string_list", + "string_map", + "bool", + "int", + "double", + "select", + "file", + "folder", + "glyph", + "color", + }, + ) + + def test_accepts_double_with_numeric_bounds(self) -> None: + self.assertEqual( + self.validate_setting( + {"type": "double", "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05} + ), + [], + ) + + def test_rejects_invalid_double_default(self) -> None: + errors = self.validate_setting({"type": "double", "default": "fast"}) + self.assertTrue(any("default must be a finite number" in error for error in errors)) + + def test_rejects_invalid_double_range(self) -> None: + errors = self.validate_setting( + {"type": "double", "default": 0.5, "min": 1.0, "max": 0.0} + ) + self.assertTrue(any("min must be less than or equal to max" in error for error in errors)) + + def test_accepts_string_map(self) -> None: + self.assertEqual( + self.validate_setting( + {"type": "string_map", "default": {"eDP-1": "laptop", "DP-1": "monitor"}} + ), + [], + ) + + def test_rejects_non_string_map_value(self) -> None: + errors = self.validate_setting({"type": "string_map", "default": {"eDP-1": 1}}) + self.assertTrue(any("default.eDP-1 must be a string" in error for error in errors)) + + def test_string_map_requires_plugin_api_6(self) -> None: + errors = self.validate_setting({"type": "string_map", "default": {}}, plugin_api=5) + self.assertTrue(any("string_map requires plugin_api >= 6" in error for error in errors)) + + class WidgetActionsTests(unittest.TestCase): def validate_actions(self, entry: dict, plugin_api: object = 14) -> list[str]: validator = validate_plugins.Validator(Path("/repo")) diff --git a/.github/workflows/validate-plugins.py b/.github/workflows/validate-plugins.py index a8bef75..901a13b 100644 --- a/.github/workflows/validate-plugins.py +++ b/.github/workflows/validate-plugins.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse import json +import math import re import sys import tomllib @@ -109,7 +110,19 @@ ROOT_STRING_FIELDS = ( ROOT_ARRAY_FIELDS = ("dependencies", "tags") ENTRY_TYPES = ("widget", "panel", "shortcut", "desktop_widget", "launcher_provider", "service") SETTING_OWNER_TYPES = ("widget", "panel", "desktop_widget", "launcher_provider") -SETTING_TYPES = {"string", "string_list", "bool", "glyph", "select", "folder", "file", "int", "color"} +SETTING_TYPES = { + "string", + "string_list", + "string_map", + "bool", + "glyph", + "select", + "folder", + "file", + "int", + "double", + "color", +} PANEL_PLACEMENTS = {"attached", "floating"} PANEL_KEYBOARD_FOCUS = {"on_demand", "exclusive", "none"} WIDGET_GESTURES = { @@ -171,6 +184,7 @@ SETTING_FIELDS = { "description_key", "default", "options", + "extensions", "min", "max", "step", @@ -885,6 +899,7 @@ class Validator: translations, entry["setting"], f"{context}.setting", + manifest.get("plugin_api"), ) if entry_count == 0: @@ -907,8 +922,23 @@ class Validator: return default = setting["default"] - if setting_type in {"string", "folder", "file"} and not isinstance(default, str): - self.add_context_error(manifest_path, context, "default must be a string") + if setting_type in {"string", "folder", "file"}: + if not isinstance(default, str): + self.add_context_error(manifest_path, context, "default must be a string") + elif setting_type == "double": + if not is_number(default) or not math.isfinite(default): + self.add_context_error(manifest_path, context, "default must be a finite number") + elif setting_type == "string_map": + if not isinstance(default, dict): + self.add_context_error(manifest_path, context, "default must be a table of string values") + else: + for key, value in default.items(): + if not isinstance(value, str): + self.add_context_error( + manifest_path, + context, + f"default.{key} must be a string", + ) elif setting_type in {"glyph", "color"} and not is_non_empty_string(default): self.add_context_error(manifest_path, context, "default must be a non-empty string") elif setting_type == "string_list": @@ -983,18 +1013,24 @@ class Validator: return values - def validate_int_bounds(self, manifest_path: Path, context: str, setting: dict[str, Any]) -> None: + def validate_numeric_bounds(self, manifest_path: Path, context: str, setting: dict[str, Any]) -> None: setting_type = setting.get("type") - bound_values: dict[str, int] = {} + bound_values: dict[str, int | float] = {} for field in ("min", "max", "step"): if field not in setting: continue value = setting[field] - if setting_type != "int": - self.add_context_error(manifest_path, context, f"{field} is only valid for int settings") - elif not is_int(value): + if setting_type not in {"int", "double"}: + self.add_context_error( + manifest_path, + context, + f"{field} is only valid for int or double settings", + ) + elif setting_type == "int" and not is_int(value): self.add_context_error(manifest_path, context, f"{field} must be an integer") + elif setting_type == "double" and (not is_number(value) or not math.isfinite(value)): + self.add_context_error(manifest_path, context, f"{field} must be a finite number") else: bound_values[field] = value @@ -1005,7 +1041,10 @@ class Validator: self.add_context_error(manifest_path, context, "step must be greater than zero") default = setting.get("default") - if setting_type == "int" and is_int(default): + valid_default = (setting_type == "int" and is_int(default)) or ( + setting_type == "double" and is_number(default) and math.isfinite(default) + ) + if valid_default: if "min" in bound_values and default < bound_values["min"]: self.add_context_error(manifest_path, context, "default must be greater than or equal to min") if "max" in bound_values and default > bound_values["max"]: @@ -1049,6 +1088,7 @@ class Validator: translations: Any | None, settings: Any, context_prefix: str, + plugin_api: Any, ) -> None: if not isinstance(settings, list): self.add_context_error(manifest_path, context_prefix, "must be an array of tables") @@ -1100,10 +1140,22 @@ class Validator: option_values = self.validate_options(manifest_path, translations, context, setting) if setting_type in SETTING_TYPES: self.validate_default(manifest_path, context, setting_type, setting, option_values) - - self.validate_int_bounds(manifest_path, context, setting) + self.validate_numeric_bounds(manifest_path, context, setting) + if setting_type == "string_map" and (not is_int(plugin_api) or plugin_api < 6): + self.add_context_error(manifest_path, context, "string_map requires plugin_api >= 6") self.validate_visible_when(manifest_path, context, setting.get("visible_when")) + if "extensions" in setting: + if setting_type != "file": + self.add_context_error(manifest_path, context, "extensions is only valid for file settings") + self.validate_string_list( + manifest_path, + context, + "extensions", + setting["extensions"], + allow_empty=True, + ) + if "advanced" in setting and not isinstance(setting["advanced"], bool): self.add_context_error(manifest_path, context, "advanced must be a bool") @@ -1315,7 +1367,13 @@ class Validator: self.validate_no_symlinks(manifest_path, plugin_dir) if "setting" in manifest: - self.validate_settings(manifest_path, translations, manifest["setting"], "setting") + self.validate_settings( + manifest_path, + translations, + manifest["setting"], + "setting", + manifest.get("plugin_api"), + ) self.validate_entries(manifest_path, manifest, translations)