Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions src/botlab/_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/botlab/sdk/_declaration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions src/botlab/sdk/_robot.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
_host_list,
_host_snapshot,
_host_value,
_localized_text,
_non_empty_string,
_policy_pd_vector,
default_joint_positions,
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/test_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Loading