tools: manage.py set refuses a number outside the key's range

was written, printed and pushed while integer() keeps its own default for a number
outside 1..8, so the switch silently did nothing - the same trap as a wrong type, one step further
in. number_in_range() reads the bounds out of the Keys table with the value, truncates like
Number.intValue() (8.9 is eight columns on the TV, so 8 is what gets written) and refuses NaN and
Infinity, which json.loads accepts and intValue() has no answer for.
This commit is contained in:
xbsheng committed 2026-10-25 22:15:01 +08:00
1 parent cc24309dd1
commit e4983fca18
2 files changed
+86 -2

No files matched your search

+52
View File
@@ -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__":
+34 -2
View File
@@ -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