diff --git a/tests/test_manage_set.py b/tests/test_manage_set.py index c7714d7c..c035cd26 100644 --- a/tests/test_manage_set.py +++ b/tests/test_manage_set.py @@ -98,6 +98,54 @@ class SetValueTest(unittest.TestCase): self.assertIn("columns", refusal) self.assertIsNone(self.written()) + def test_a_number_key_refuses_a_value_outside_the_tables_range(self): + # integer() keeps its own default for a number outside min..max, so set must not write one either. + for key, value in (("columns", "9"), ("columns", "0"), ("rows_below_dock", "4"), + ("sky_intensity", "101"), ("clock_wallpaper_dim", "91"), ("dock_opacity", "29")): + with self.subTest(key=key, value=value): + self.config.unlink(missing_ok=True) + refusal = self.set_key(key, value) + self.assertIn(key, refusal) + self.assertIn("out of range", refusal) + self.assertIn(value, refusal) + self.assertIsNone(self.written(), "a refused value must not touch the theme") + self.push.assert_not_called() + + def test_a_number_key_takes_both_ends_of_its_range(self): + # The bounds are read out of the Keys table, so a range that moves there moves here with it. + numbers = {k: v for k, v in keys_table_types().items() if manage.declared_kind({"type": v}) == "number"} + self.assertGreaterEqual(len(numbers), 10, "the Keys table's number keys were read") + for key, cell in sorted(numbers.items()): + low, high = manage.declared_range(cell) + with self.subTest(key=key, value=low): + self.config.unlink(missing_ok=True) + self.assertIsNone(self.set_key(key, str(low))) + self.assertEqual(low, self.written()[key]) + with self.subTest(key=key, value=high): + self.assertIsNone(self.set_key(key, str(high))) + self.assertEqual(high, self.written()[key]) + + def test_a_number_key_writes_the_int_the_launcher_would_read(self): + # integer() does ((Number) v).intValue(), so 8.9 is eight columns on the TV: write 8, not 8.9. + self.assertIsNone(self.set_key("columns", "8.9")) + self.assertEqual(8, self.written()["columns"]) + self.assertIsInstance(self.written()["columns"], int) + + def test_a_number_key_refuses_nan_and_infinity(self): + # json.loads accepts these three; intValue() has no answer for them, so the key keeps its default. + for text in ("NaN", "Infinity", "-Infinity"): + with self.subTest(value=text): + self.config.unlink(missing_ok=True) + self.assertIn("finite", self.set_key("columns", text)) + self.assertIsNone(self.written()) + self.push.assert_not_called() + + def test_a_range_cell_is_read_as_its_two_bounds(self): + self.assertEqual((1, 8), manage.declared_range("1 to 8")) + self.assertEqual((30, 100), manage.declared_range("30 to 100")) + self.assertEqual((None, None), manage.declared_range("bool")) + self.assertEqual((None, None), manage.declared_range("1 to 8 if top_menu")) + def test_an_object_key_refuses_a_word_and_takes_a_json_object(self): refusal = self.set_key("wallpaper_moods", "nope") self.assertIn("wallpaper_moods", refusal) @@ -151,6 +199,10 @@ class SetValueTest(unittest.TestCase): self.assertIn("return v instanceof Boolean ? (Boolean) v : fallback;", config) self.assertIn('return v instanceof String ? ((String) v).trim() : fallback;', config) self.assertIn("if (!(v instanceof Number)) return fallback;", config) + # integer() truncates like Number.intValue() and keeps its default outside min..max: the two rules + # set's number handling copies. + self.assertIn("int i = ((Number) v).intValue();", config) + self.assertIn("return i < min || i > max ? fallback : i;", config) if __name__ == "__main__": diff --git a/tools/manage.py b/tools/manage.py index aa6d605e..84b0d407 100644 --- a/tools/manage.py +++ b/tools/manage.py @@ -14,9 +14,11 @@ build-and-install.sh, or the steps in addons/README.md; tools/theme.sh for theme install on an installed add-on updates it (pm install -r). set writes the key into a theme folder of your own and pushes it with tools/theme.sh, as themes/README.md describes; it never edits the project's themes. set reads the value the way the key's own type in the Keys table needs - true or false for a bool, a JSON list, object or number -where the table says so, the text itself for a string - and refuses one the launcher would ignore. +where the table says so, the text itself for a string - and refuses one the launcher would ignore or a number +outside the range the table states. """ import json +import math import os import re import subprocess @@ -156,11 +158,39 @@ def declared_kind(item): return "list" if cell == "object": return "object" - if re.fullmatch(r"\d+(?:\.\d+)? to \d+(?:\.\d+)?", cell): + if declared_range(cell) != (None, None): return "number" return "string" if cell else None +def declared_range(cell): + """The (low, high) of a range cell such as '1 to 8', or (None, None) when the cell is not a range.""" + m = re.fullmatch(r"(-?\d+) to (-?\d+)", cell or "") + return (int(m.group(1)), int(m.group(2))) if m else (None, None) + + +def number_in_range(key, item, parsed, text): + """An int the launcher's integer() would keep: truncated like Number.intValue(), inside the table's range. + + integer() is `int i = ((Number) v).intValue(); return i < min || i > max ? fallback : i;`, so a float is + truncated on the TV and anything outside the range is silently the default. The range comes from the Keys + table, so the value set writes is one the launcher reads back. (lunar_utc_offset's floor in Config.java is + -1, the "by locale" sentinel the table's Meaning column calls Unset rather than a number to pass.) + """ + cell = (item.get("type") or "").strip() + low, high = declared_range(cell) + if low is None or high is None: + # declared_kind() only sends a range cell here; a cell whose shape changed must not pass unchecked. + sys.exit(f"{key} has no range in the Keys table, so there is nothing to check {text} against") + # json.loads accepts NaN and Infinity; int() has no answer for them and integer() keeps the default. + if isinstance(parsed, float) and not math.isfinite(parsed): + sys.exit(f"{key} takes {cell}, so pass a finite number, not {text}") + value = int(parsed) + if not low <= value <= high: + sys.exit(f"{key} takes {cell}, so {text} is out of range and the launcher would keep the default") + return value + + def value_as(key, item, text): """The argument read the way the key's type needs, or a refusal naming what the key takes.""" kind = declared_kind(item) @@ -186,6 +216,8 @@ def value_as(key, item, text): if not isinstance(parsed, wanted) or isinstance(parsed, bool): example = {"list": '["a", "b"]', "object": '{"key": "value"}', "number": "6"}[kind] sys.exit(f"{key} takes {item.get('type')}, so pass {example}, not {text}") + if kind == "number": + return number_in_range(key, item, parsed, text) return parsed