diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..b138ab8 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,89 @@ +"""The page and the node are one contract, checked here rather than by eye. + +`ui.py` translates for a console it cannot import, and the page reads telemetry +by key. Nothing in either language catches a rename, so a typo would surface as +a dead button on a machine that can move — which is the wrong place to find it. +""" + +import json +import re +from pathlib import Path + +from dora_teleop import ui + +PAGE = Path(__file__).resolve().parent.parent / "web" / "index.html" + + +def page(): + return PAGE.read_text() + + +def sent_types(): + """Return every message type the page can send.""" + return set(re.findall(r'type:\s*"([a-z_]+)"', page())) + + +def read_keys(): + """Return the telemetry keys the page renders by name.""" + listed = re.search(r"const KEYS = \[(.*?)\]", page(), re.DOTALL).group(1) + return set(re.findall(r'"([a-z_]+)"', listed)) + + +def snapshot_keys(): + view = ui.ArmView() + view.pose = [1.0] * 6 + return set(ui.snapshot(view, "web", False, 0.0)) + + +def test_every_message_the_page_sends_is_one_the_node_serves(): + # `data` is the bridge's own envelope, not a console command. + for kind in sent_types() - {"data"}: + assert ui.command_of({"type": kind}) is not None or kind == "jog", kind + + +def test_every_telemetry_key_the_page_reads_is_one_the_node_sends(): + missing = read_keys() - snapshot_keys() + + assert not missing, f"the page reads keys the node does not send: {sorted(missing)}" + + +def test_the_page_reads_the_pose_units_from_the_node(): + # Rather than assuming mm, which is only true of one robot. + assert "units.position" in page().replace("telemetry.units && telemetry.units.position", "units.position") + assert "units" in snapshot_keys() + + +def test_the_jog_the_page_sends_is_normalised_and_accepted(): + # Six axes, each a fraction of full deflection. If the page ever sent a + # velocity instead, it would have to know the robot's units. + command = ui.command_of(json.loads('{"type": "jog", "axes": [0, -0.6, 0, 0, 0, 0]}')) + + assert command is not None + assert command.axes == [0.0, -0.6, 0.0, 0.0, 0.0, 0.0] + + +def test_the_page_can_ask_for_everything_the_node_serves(): + # `move` is the one command with no button: it is a pose in the robot's own + # units, which is exactly what this page is built not to know. + served = {"jog", "jog_stop", "estop", "reset", "home", "control", "gripper", "freedrive"} + + assert served <= sent_types() + + +def test_the_page_gates_its_jog_pad_on_holding_the_lease(): + # The node refuses a jog from a non-owner and answers with a toast; a pad + # that looks live but is refused teaches an operator to distrust the screen. + assert 'owner === "web"' in page() + + +def test_the_published_schemas_do_not_depend_on_the_value_sent(): + # pyarrow reads a null `value` as its own type, so an inferred schema would + # change the type of the `command` port between one verb and the next. + from dora_teleop import message + + assert message.pack_command("stop", None).type == message.COMMAND + assert message.pack_command("freedrive", 1.0).type == message.COMMAND + assert ( + message.pack_twist({"linear": [0.0] * 3, "angular": [0.0] * 3, "frame": "base"}).type == message.TWIST + ) + assert message.pack_jog([0.0] * 6).type == message.JOG diff --git a/web/index.html b/web/index.html index ad6a905..6a4a165 100644 --- a/web/index.html +++ b/web/index.html @@ -3,47 +3,137 @@ -teleop — placeholder console +teleop console -

teleop — placeholder console

+

teleop console

- Not the real console. This page exists so the dataflow has something to serve and so the - node can be checked end to end before the Control section is ported across from the - monolithic repo. It reads telemetry and can take, release and stop — nothing else. + Hold a direction to move; release to stop. The page never sends a speed in the robot's + units — it sends a fraction of full deflection, exactly as a gamepad stick does, and the + node scales it. That is why this console does not need to know what robot is on the + other end.

-
-
- - - - +

State

+
+
+

Control

+
+ + + + +
+
+ +
+

Move

+
+
+
translation
+
+ + + + + + + + + +
+
+
+
rotation
+
+ + + + + + + + + +
+
+
+

+ +
+ +
+

Tool

+
+ + + + +
+
+ +