Files
orisys-app/apps/autocal/tests/test_locales_autocal.py
T
ykcheungab 5339624ffa 把存储的 yaw 上限归一到 ±180
yaw 台转 ±180,实测点的第二组精确解落在 118° 之外:旧文件里的 ±90 会让求解器把位姿算成可达、运动守卫却拒绝这段 yaw,操作员看到的就是“到不了”。加载时把求解器与守卫两种写法的 yaw 上限一起归一,pitch 包络此前已按同一理由处理。
2026-10-14 20:27:21 +08:00

167 lines
5.9 KiB
Python

"""Locale files: same keys everywhere, and a real fallback.
``Translator`` returns the key itself when a string is missing, which is silent
in production — a label would simply read ``axes.nope``. The studio suite guards
this by comparing key sets; the same guard applies here.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from orisys_autocal.preferences import load_preferences
LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales"
LANGUAGES = ("zh_CN", "en_US")
def _load(language: str) -> dict:
return json.loads((LOCALES / f"{language}.json").read_text(encoding="utf-8"))
def _flatten(node: dict, prefix: str = "") -> set[str]:
keys: set[str] = set()
for key, value in node.items():
path = f"{prefix}{key}"
if isinstance(value, dict):
keys |= _flatten(value, f"{path}.")
else:
keys.add(path)
return keys
def test_every_locale_file_exists():
for language in LANGUAGES:
assert (LOCALES / f"{language}.json").is_file(), language
def test_locales_have_identical_key_sets():
reference = _flatten(_load("zh_CN"))
for language in LANGUAGES[1:]:
other = _flatten(_load(language))
assert other == reference, {
f"{language}_only": sorted(other - reference),
"zh_CN_only": sorted(reference - other),
}
def test_no_translation_is_empty_where_a_label_is_expected():
"""A blank string renders as an empty widget, which looks like a bug."""
for language in LANGUAGES:
for key, value in _walk(_load(language)):
assert str(value).strip(), f"{language}: {key} is blank"
def test_the_product_name_is_localised():
"""The window title is Chinese in zh_CN and English in en_US."""
assert _load("zh_CN")["app"]["title"] != _load("en_US")["app"]["title"]
@pytest.mark.parametrize("language", LANGUAGES)
def test_every_locale_loads_through_the_translator(language):
from orisys_app_shared.i18n import Translator
translator = Translator(LOCALES, locale=language)
for key in sorted(_flatten(_load(language))):
assert translator.t(key) != key, f"{language}: {key} did not resolve"
def test_the_default_preferences_file_matches_the_schema_shape():
"""A shipped default that names a key the app does not have is dead weight."""
shipped = json.loads(
(LOCALES.parent / "preferences.default.json").read_text(encoding="utf-8")
)
schema = load_preferences()
for key in shipped:
assert key in schema, f"preferences.default.json has an unknown key: {key}"
def test_the_shipped_defaults_carry_the_controller_task_number():
"""Re-initialisation names a task; if this key is missing every re-init is wrong."""
from orisys_autocal.motion import DEFAULT_BAS_TASK
shipped = json.loads(
(LOCALES.parent / "preferences.default.json").read_text(encoding="utf-8")
)
assert shipped["bas_task"] == DEFAULT_BAS_TASK == 4
def test_a_stored_block_without_a_new_limit_still_gets_it():
"""The upgrade path for a real preference file, which is merged one level deep.
A stored ``safety_limits`` replaces the whole default block, so a limit added
later would read as "not set" — and motion is refused for a limit the operator
cannot see anywhere in the file. Filling the block key by key is what stops that.
"""
from orisys_app_shared.paths import preference_path
from orisys_autocal.preferences import load_preferences
path = preference_path()
path.parent.mkdir(parents=True, exist_ok=True)
# A file written before the stage limits existed, with a chosen travel limit.
path.write_text(
json.dumps(
{
"safety_limits": {"max_travel_mm": 42.0},
"sensor_setup": {"press_axis": 4},
"axis_roles": {"x": 0, "y": 1, "pitch": 2, "yaw": 3},
}
),
encoding="utf-8",
)
prefs = load_preferences()
limits = prefs["safety_limits"]
assert limits["max_travel_mm"] == 42.0, "a chosen limit was overwritten"
assert limits["max_yaw_deg"] > 0 and limits["max_pitch_deg"] > 0
assert limits["contact_search_mm"] > 0
# The old duplicate press axis moves into the mapping, and the key goes.
assert prefs["axis_roles"]["z"] == 4
assert "press_axis" not in prefs["sensor_setup"]
def test_a_stored_quarter_turn_yaw_limit_is_normalised_to_the_bench_travel():
"""A stored ±90 makes a solvable pose read as unreachable.
The yaw stage turns ±180, and a measured point's second exact solution sits past
118° of yaw: at ±90 the solver is left with one branch and the move guard refuses
the other, so a pose the preview aligned reports "cannot reach". Both spellings of
the limit — the solver's ``finger_yaw_limit_deg`` and the guard's
``safety_limits.max_yaw_deg`` — are the same bench fact, so both are normalised.
"""
from orisys_app_shared.paths import preference_path
from orisys_autocal.preferences import load_preferences
path = preference_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"safety_limits": {"max_yaw_deg": 90.0, "max_pitch_deg": 360.0},
"finger_yaw_limit_deg": 90.0,
"finger_pitch_limit_deg": 360.0,
}
),
encoding="utf-8",
)
prefs = load_preferences()
assert prefs["safety_limits"]["max_yaw_deg"] == 180.0
assert prefs["finger_yaw_limit_deg"] == 180.0
# The pitch envelope keeps the correction it already had, so the two do not drift.
assert prefs["safety_limits"]["max_pitch_deg"] == 90.0
assert prefs["finger_pitch_limit_deg"] == 90.0
def _walk(node, prefix: str = ""):
for key, value in node.items():
path = f"{prefix}{key}"
if isinstance(value, dict):
yield from _walk(value, f"{path}.")
else:
yield path, value