From 7173550d823766b5d669cef8c62df9dda3c88fcf Mon Sep 17 00:00:00 2001 From: cmjang Date: Wed, 28 Oct 2026 03:55:41 -0400 Subject: [PATCH] feat: accept {en, zh} tables for parameter and key mapping labels --- src/botlab/_host.py | 30 ++++++++++++++++++++++++++++-- src/botlab/sdk/_declaration.py | 27 +++++++++++++++++++++++++++ src/botlab/sdk/_robot.py | 6 ++---- tests/test_host.py | 21 +++++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/botlab/_host.py b/src/botlab/_host.py index c700c57..c2fa43b 100644 --- a/src/botlab/_host.py +++ b/src/botlab/_host.py @@ -70,6 +70,32 @@ def _text(value: Any, label: str) -> str: return value +# 展示文案支持的界面语言;与 BotLab 前端 localizedText.ts、SDK _declaration.py 保持一致。 +_TEXT_LOCALES = ("en", "zh") + + +def _localized_text(value: Any, label: str) -> str | dict: + """Display text: a non-empty string, or {"en": ..., "zh": ...} with "en" as fallback.""" + if isinstance(value, str): + return _text(value, label) + if not isinstance(value, dict): + raise BotLabError(f'[botlab] {label} must be a non-empty string or a {{"en": ..., "zh": ...}} mapping') + unknown = sorted(set(value) - set(_TEXT_LOCALES)) + if unknown: + raise BotLabError(f"[botlab] {label} has unsupported locales {unknown} (expected {list(_TEXT_LOCALES)})") + if "en" not in value: + raise BotLabError(f'[botlab] {label} must include an "en" entry') + table = {} + for locale in _TEXT_LOCALES: + if locale not in value: + continue + text = value[locale] + if not isinstance(text, str) or not text.strip(): + raise BotLabError(f"[botlab] {label}.{locale} must be a non-empty string") + table[locale] = text.strip() + return table + + def _json_value(value: Any, label: str, _stack: frozenset[int] = frozenset()) -> Any: """Normalize a Python declaration value to its JS counterpart. @@ -144,7 +170,7 @@ def _declared_parameter(pid: str, value: Any) -> dict: for option in value["options"]: if not isinstance(option, dict) or set(option) != {"label", "value"}: raise BotLabError(f"[botlab] Parameter {pid!r} option requires only label and value") - label = _text(option["label"], f"Parameter {pid!r} option label") + label = _localized_text(option["label"], f"Parameter {pid!r} option label") number = _finite_number(option["value"], f"Parameter {pid!r} option value") if number != int(number) or abs(number) > 9007199254740991: raise BotLabError(f"[botlab] Parameter {pid!r} option value must be a safe integer") @@ -179,7 +205,7 @@ def _declared_parameter(pid: str, value: Any) -> dict: if step is not None: result["step"] = step if "label" in value: - result["label"] = _text(value["label"], f"Parameter {pid!r} label") + result["label"] = _localized_text(value["label"], f"Parameter {pid!r} label") if "unit" in value: result["unit"] = _text(value["unit"], f"Parameter {pid!r} unit") return result diff --git a/src/botlab/sdk/_declaration.py b/src/botlab/sdk/_declaration.py index c33b426..f049633 100644 --- a/src/botlab/sdk/_declaration.py +++ b/src/botlab/sdk/_declaration.py @@ -46,6 +46,33 @@ def _non_empty_string(value, label): return value +# 展示文案支持的界面语言;与前端 src/domain/low-code/localizedText.ts 保持一致。 +_TEXT_LOCALES = ("en", "zh") + + +def _localized_text(value, label): + """Display text: a non-empty string, or {"en": ..., "zh": ...} with "en" as fallback.""" + if isinstance(value, str): + # 纯字符串沿用原有规则,已有工程的计划输出不变。 + return _non_empty_string(value, label) + if not isinstance(value, dict): + raise TypeError(f'{label} must be a non-empty string or a {{"en": ..., "zh": ...}} mapping') + unknown = sorted(set(value) - set(_TEXT_LOCALES)) + if unknown: + raise ValueError(f"{label} has unsupported locales {unknown} (expected {list(_TEXT_LOCALES)})") + if "en" not in value: + raise ValueError(f'{label} must include an "en" entry') + table = {} + for locale in _TEXT_LOCALES: + if locale not in value: + continue + text = value[locale] + if not isinstance(text, str) or not text.strip(): + raise ValueError(f"{label}.{locale} must be a non-empty string") + table[locale] = text.strip() + return table + + def _plan_value(value, label): if isinstance(value, _ResourceValues): value = value._plan diff --git a/src/botlab/sdk/_robot.py b/src/botlab/sdk/_robot.py index 4d02559..55a58cf 100644 --- a/src/botlab/sdk/_robot.py +++ b/src/botlab/sdk/_robot.py @@ -7,6 +7,7 @@ _host_list, _host_snapshot, _host_value, + _localized_text, _non_empty_string, _policy_pd_vector, default_joint_positions, @@ -630,10 +631,7 @@ def commands( ) normalized = {**key_data, "values": values} if "label" in mapping: - normalized["label"] = _non_empty_string( - mapping["label"], - f"{label} label", - ) + normalized["label"] = _localized_text(mapping["label"], f"{label} label") normalized_mappings.append(normalized) data["commandEncoding"] = "mapped" data["commandMappings"] = normalized_mappings diff --git a/tests/test_host.py b/tests/test_host.py index 06b2669..02b08f6 100644 --- a/tests/test_host.py +++ b/tests/test_host.py @@ -150,3 +150,24 @@ def test_option_parameter_membership(session): def test_invalid_option_parameters(session, spec): with pytest.raises(BotLabError): session.declare_project("Options", 50, 200, {"mode": spec}) + + +def test_localized_parameter_labels(): + spec = _host._declared_parameter("mode", { + "label": {"en": " Mode ", "zh": "模式"}, + "default": 2, + "options": [{"label": {"en": "Walk", "zh": "行走"}, "value": 2}], + }) + assert spec["label"] == {"en": "Mode", "zh": "模式"} + assert spec["options"] == [{"label": {"en": "Walk", "zh": "行走"}, "value": 2}] + + +@pytest.mark.parametrize(("label", "message"), [ + ({"zh": "速度"}, 'must include an "en" entry'), + ({"en": "Speed", "fr": "Vitesse"}, "unsupported locales"), + ({"en": "Speed", "zh": " "}, "zh must be a non-empty string"), + (["Speed"], "must be a non-empty string or a"), +]) +def test_invalid_localized_parameter_labels(label, message): + with pytest.raises(BotLabError, match=message): + _host._declared_parameter("speed", {"default": 0, "label": label})