diff --git a/examples/language-interop/README.md b/examples/language-interop/README.md index 52ae561ddb..a099977a53 100644 --- a/examples/language-interop/README.md +++ b/examples/language-interop/README.md @@ -14,6 +14,7 @@ Demonstrates controlling a dimos robot from non-Python languages. - [TypeScript](ts/) - CLI and browser-based web UI - [C++](cpp/) - [Lua](lua/) + - [Rust](rust/) 3. (Optional) Monitor traffic with `lcmspy` diff --git a/examples/language-interop/rust/.gitignore b/examples/language-interop/rust/.gitignore new file mode 100644 index 0000000000..b83d22266a --- /dev/null +++ b/examples/language-interop/rust/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/examples/language-interop/rust/Cargo.lock b/examples/language-interop/rust/Cargo.lock new file mode 100644 index 0000000000..6accfdb45b --- /dev/null +++ b/examples/language-interop/rust/Cargo.lock @@ -0,0 +1,123 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "dimos-lcm" +version = "0.1.0" +source = "git+https://github.com/dimensionalOS/dimos-lcm.git?rev=474b25d0f9f88b8430753df2453fd2c988a514d1#474b25d0f9f88b8430753df2453fd2c988a514d1" +dependencies = [ + "byteorder", + "socket2", +] + +[[package]] +name = "lcm-msgs" +version = "0.1.0" +source = "git+https://github.com/dimensionalOS/dimos-lcm.git?rev=474b25d0f9f88b8430753df2453fd2c988a514d1#474b25d0f9f88b8430753df2453fd2c988a514d1" +dependencies = [ + "byteorder", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "robot-control" +version = "0.1.0" +dependencies = [ + "dimos-lcm", + "lcm-msgs", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/examples/language-interop/rust/Cargo.toml b/examples/language-interop/rust/Cargo.toml new file mode 100644 index 0000000000..f07f98fb3e --- /dev/null +++ b/examples/language-interop/rust/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "robot-control" +version = "0.1.0" +edition = "2021" + +[dependencies] +# TODO: switch to version once dimos-lcm#20 merges +dimos-lcm = { git = "https://github.com/dimensionalOS/dimos-lcm.git", rev = "474b25d0f9f88b8430753df2453fd2c988a514d1" } +# TODO: switch to version once dimos-lcm#20 merges +lcm-msgs = { git = "https://github.com/dimensionalOS/dimos-lcm.git", rev = "474b25d0f9f88b8430753df2453fd2c988a514d1" } diff --git a/examples/language-interop/rust/README.md b/examples/language-interop/rust/README.md new file mode 100644 index 0000000000..63384f7f4f --- /dev/null +++ b/examples/language-interop/rust/README.md @@ -0,0 +1,14 @@ +# Rust Robot Control Example + +Subscribes to `/odom` and publishes velocity commands to `/cmd_vel` via LCM UDP multicast. + +## Build & Run + +```bash +cargo run +``` + +## Dependencies + +- [Rust toolchain](https://rustup.rs/) +- Message types fetched automatically from [dimos-lcm](https://github.com/dimensionalOS/dimos-lcm) (`rust-codegen` branch) diff --git a/examples/language-interop/rust/src/main.rs b/examples/language-interop/rust/src/main.rs new file mode 100644 index 0000000000..97d00ce8cf --- /dev/null +++ b/examples/language-interop/rust/src/main.rs @@ -0,0 +1,78 @@ +// Rust robot control example +// Subscribes to robot pose and publishes twist commands via LCM + +use dimos_lcm::Lcm; +use lcm_msgs::geometry_msgs::{PoseStamped, Twist, Vector3}; +use std::thread; +use std::time::{Duration, Instant}; + +const ODOM_CHANNEL: &str = "/odom#geometry_msgs.PoseStamped"; +const CMD_VEL_CHANNEL: &str = "/cmd_vel#geometry_msgs.Twist"; +const PUBLISH_INTERVAL: Duration = Duration::from_millis(100); // 10 Hz + +fn main() { + let lcm = Lcm::new().expect("Failed to create LCM transport"); + + println!("Robot control started"); + println!("Subscribing to /odom, publishing to /cmd_vel"); + println!("Press Ctrl+C to stop.\n"); + + let mut t: f64 = 0.0; + let mut next_publish = Instant::now(); + + loop { + // Poll for incoming messages + match lcm.try_recv() { + Ok(Some(msg)) if msg.channel == ODOM_CHANNEL => { + match PoseStamped::decode(&msg.data) { + Ok(pose) => { + let pos = &pose.pose.position; + let ori = &pose.pose.orientation; + println!( + "[pose] x={:.2} y={:.2} z={:.2} | qw={:.2}", + pos.x, pos.y, pos.z, ori.w + ); + } + Err(e) => eprintln!("[pose] decode error: {e}"), + } + } + Ok(Some(_)) => {} // ignore other channels + Ok(None) => {} + Err(e) => eprintln!("recv error: {e}"), + } + + // Publish twist at 10 Hz + let now = Instant::now(); + if now >= next_publish { + let twist = Twist { + linear: Vector3 { + x: 0.5, + y: 0.0, + z: 0.0, + }, + angular: Vector3 { + x: 0.0, + y: 0.0, + z: t.sin() * 0.3, + }, + }; + + let data = twist.encode(); + if let Err(e) = lcm.publish(CMD_VEL_CHANNEL, &data) { + eprintln!("[twist] publish error: {e}"); + } else { + println!( + "[twist] linear={:.2} angular={:.2}", + twist.linear.x, twist.angular.z + ); + } + + t += 0.1; + next_publish = now + PUBLISH_INTERVAL; + } + + // Sleep until next publish deadline (capped at 10ms) to avoid busy-spinning + let sleep_dur = next_publish.saturating_duration_since(Instant::now()).min(Duration::from_millis(10)); + thread::sleep(sleep_dur); + } +} diff --git a/examples/language-interop/tests/__init__.py b/examples/language-interop/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/language-interop/tests/conftest.py b/examples/language-interop/tests/conftest.py new file mode 100644 index 0000000000..d206828fc5 --- /dev/null +++ b/examples/language-interop/tests/conftest.py @@ -0,0 +1,146 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fixtures for language-interop integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + +import pytest + +EXAMPLES_DIR = Path(__file__).resolve().parent.parent # language-interop/ +SIMPLEROBOT_DIR = EXAMPLES_DIR.parent / "simplerobot" +RUST_DIR = EXAMPLES_DIR / "rust" +TS_DIR = EXAMPLES_DIR / "ts" +CPP_DIR = EXAMPLES_DIR / "cpp" +LUA_DIR = EXAMPLES_DIR / "lua" + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "interop: cross-language interop integration test") + + +@pytest.fixture(scope="module") +def simplerobot() -> Generator[subprocess.Popen[str], None, None]: + """Start simplerobot.py --headless as a subprocess, tear down after tests.""" + proc = subprocess.Popen( + [sys.executable, str(SIMPLEROBOT_DIR / "simplerobot.py"), "--headless"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + cwd=str(SIMPLEROBOT_DIR), + ) + # Give it time to start publishing + time.sleep(2) + yield proc + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + + +@pytest.fixture(scope="module") +def rust_binary() -> Path: + """Build the Rust interop binary and return its path.""" + cargo_toml = RUST_DIR / "Cargo.toml" + if not cargo_toml.exists(): + pytest.skip("Rust example not found") + + env = os.environ.copy() + cargo_home = Path.home() / ".cargo" / "bin" + if cargo_home.is_dir(): + env["PATH"] = str(cargo_home) + os.pathsep + env.get("PATH", "") + + result = subprocess.run( + ["cargo", "build", "--release"], + cwd=str(RUST_DIR), + capture_output=True, + text=True, + timeout=120, + env=env, + ) + if result.returncode != 0: + pytest.skip(f"cargo build failed: {result.stderr}") + + # Binary name from Cargo.toml [package] name = "robot-control" + binary = RUST_DIR / "target" / "release" / "robot-control" + if not binary.exists(): + # Try to find any binary in release + release_dir = RUST_DIR / "target" / "release" + found = next( + ( + f + for f in release_dir.iterdir() + if f.is_file() and os.access(f, os.X_OK) and not f.suffix and ".so" not in f.name + ), + None, + ) + if found is None: + pytest.skip("No binary found after cargo build") + binary = found + + return binary + + +@pytest.fixture(scope="module") +def cpp_binary() -> Path: + """Build the C++ interop binary and return its path.""" + cmakelists = CPP_DIR / "CMakeLists.txt" + if not cmakelists.exists(): + pytest.skip("C++ example not found") + + build_dir = CPP_DIR / "build" + build_dir.mkdir(exist_ok=True) + + cmake_result = subprocess.run( + ["cmake", ".."], + cwd=str(build_dir), + capture_output=True, + text=True, + timeout=30, + ) + if cmake_result.returncode != 0: + pytest.skip(f"cmake failed: {cmake_result.stderr}") + + make_result = subprocess.run( + ["make", "-j4"], + cwd=str(build_dir), + capture_output=True, + text=True, + timeout=60, + ) + if make_result.returncode != 0: + pytest.skip(f"make failed: {make_result.stderr}") + + # Find the built binary + binary = next( + ( + f + for f in build_dir.iterdir() + if f.is_file() and os.access(f, os.X_OK) and not f.suffix and ".so" not in f.name + ), + None, + ) + if binary is None: + pytest.skip("No C++ binary found after build") + return binary diff --git a/examples/language-interop/tests/test_cpp_interop.py b/examples/language-interop/tests/test_cpp_interop.py new file mode 100644 index 0000000000..356dc7446f --- /dev/null +++ b/examples/language-interop/tests/test_cpp_interop.py @@ -0,0 +1,51 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration test: C++ robot-control binary talks to Python simplerobot via LCM.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess + +import pytest + +pytestmark = pytest.mark.interop + + +def test_cpp_receives_pose_and_publishes_twist( + simplerobot: subprocess.Popen[str], + cpp_binary: Path, +) -> None: + """Run the C++ binary for a few seconds and verify message exchange.""" + try: + result = subprocess.run( + [str(cpp_binary)], + capture_output=True, + text=True, + timeout=5, + ) + except subprocess.TimeoutExpired as e: + stdout = e.stdout or "" + stderr = e.stderr or "" + else: + stdout = result.stdout + stderr = result.stderr + + assert "[pose]" in stdout, ( + f"C++ binary never received a PoseStamped.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) + assert "[twist]" in stdout, ( + f"C++ binary never published a Twist.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) diff --git a/examples/language-interop/tests/test_lua_interop.py b/examples/language-interop/tests/test_lua_interop.py new file mode 100644 index 0000000000..6ea740fdba --- /dev/null +++ b/examples/language-interop/tests/test_lua_interop.py @@ -0,0 +1,61 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration test: Lua robot-control script talks to Python simplerobot via LCM.""" + +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +from .conftest import LUA_DIR + +pytestmark = pytest.mark.interop + + +@pytest.fixture(scope="module") +def lua_available() -> None: + if shutil.which("lua") is None and shutil.which("lua5.4") is None: + pytest.skip("lua not found on PATH") + + +def test_lua_receives_pose_and_publishes_twist( + simplerobot: subprocess.Popen[str], + lua_available: None, +) -> None: + """Run the Lua script for a few seconds and verify message exchange.""" + lua_bin = shutil.which("lua") or shutil.which("lua5.4") or "lua" + try: + result = subprocess.run( + [lua_bin, str(LUA_DIR / "main.lua")], + capture_output=True, + text=True, + timeout=5, + cwd=str(LUA_DIR), + ) + except subprocess.TimeoutExpired as e: + stdout = e.stdout or "" + stderr = e.stderr or "" + else: + stdout = result.stdout + stderr = result.stderr + + assert "[pose]" in stdout, ( + f"Lua script never received a PoseStamped.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) + assert "[twist]" in stdout, ( + f"Lua script never published a Twist.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) diff --git a/examples/language-interop/tests/test_rust_interop.py b/examples/language-interop/tests/test_rust_interop.py new file mode 100644 index 0000000000..e563cbd56d --- /dev/null +++ b/examples/language-interop/tests/test_rust_interop.py @@ -0,0 +1,72 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration test: Rust robot-control binary talks to Python simplerobot via LCM.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess + +import pytest + +pytestmark = pytest.mark.interop + + +def _run_rust_binary(rust_binary: Path, timeout: int = 5) -> tuple[str, str]: + """Run the Rust binary and return (stdout, stderr).""" + try: + result = subprocess.run( + [str(rust_binary)], + capture_output=True, + text=True, + timeout=timeout, + ) + return result.stdout, result.stderr + except subprocess.TimeoutExpired as e: + # text=True means stdout/stderr are str, but stubs type them as bytes | str | None + return str(e.stdout or ""), str(e.stderr or "") + + +def test_rust_binary_publishes_twist( + simplerobot: subprocess.Popen[str], + rust_binary: Path, +) -> None: + """Rust binary starts up and publishes Twist commands.""" + stdout, stderr = _run_rust_binary(rust_binary) + + assert "[twist]" in stdout, ( + f"Rust binary never published a Twist.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) + + +@pytest.mark.skipif( + os.environ.get("CI_NO_MULTICAST") is not None, + reason="multicast unavailable", +) +def test_rust_binary_receives_pose( + simplerobot: subprocess.Popen[str], + rust_binary: Path, +) -> None: + """Rust binary receives PoseStamped from simplerobot via LCM multicast. + + This test requires working UDP multicast between processes. + """ + stdout, stderr = _run_rust_binary(rust_binary) + + assert "[pose]" in stdout, ( + f"Rust binary never received a PoseStamped from simplerobot.\n" + f"stdout: {stdout!r}\nstderr: {stderr!r}" + ) diff --git a/examples/language-interop/tests/test_ts_interop.py b/examples/language-interop/tests/test_ts_interop.py new file mode 100644 index 0000000000..80adec20ba --- /dev/null +++ b/examples/language-interop/tests/test_ts_interop.py @@ -0,0 +1,60 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration test: TypeScript (Deno) robot-control talks to Python simplerobot via LCM.""" + +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +from .conftest import TS_DIR + +pytestmark = pytest.mark.interop + + +@pytest.fixture(scope="module") +def deno_available() -> None: + if shutil.which("deno") is None: + pytest.skip("deno not found on PATH") + + +def test_ts_receives_pose_and_publishes_twist( + simplerobot: subprocess.Popen[str], + deno_available: None, +) -> None: + """Run the Deno TS script for a few seconds and verify message exchange.""" + try: + result = subprocess.run( + ["deno", "run", "--allow-net", "--unstable-net", str(TS_DIR / "main.ts")], + capture_output=True, + text=True, + timeout=5, + cwd=str(TS_DIR), + ) + except subprocess.TimeoutExpired as e: + stdout = e.stdout or "" + stderr = e.stderr or "" + else: + stdout = result.stdout + stderr = result.stderr + + assert "[pose]" in stdout, ( + f"TS script never received a PoseStamped.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) + assert "[twist]" in stdout, ( + f"TS script never published a Twist.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) diff --git a/examples/language-interop/tests/test_wire_compat.py b/examples/language-interop/tests/test_wire_compat.py new file mode 100644 index 0000000000..4f463febf8 --- /dev/null +++ b/examples/language-interop/tests/test_wire_compat.py @@ -0,0 +1,117 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Wire-format compatibility tests. + +Validates that Python LCM encoding matches known binary layouts from the Rust +dimos-lcm implementation, ensuring cross-language wire compatibility without +needing to build or run any non-Python code. +""" + +from __future__ import annotations + +import struct + +from dimos.msgs.geometry_msgs.Twist import Twist +from dimos.msgs.geometry_msgs.Vector3 import Vector3 + +# Known fingerprints from dimos-lcm Rust tests (roundtrip.rs) +VECTOR3_FINGERPRINT = 0xAE7E5FBA5EECA11E +TWIST_FINGERPRINT = 0x2E7C07D7CDF7E027 + + +def test_vector3_fingerprint() -> None: + """Python Vector3 encoding starts with the same fingerprint as Rust.""" + v = Vector3(1.5, 2.5, 3.5) + encoded = v.lcm_encode() + fingerprint = struct.unpack(">Q", encoded[:8])[0] + assert fingerprint == VECTOR3_FINGERPRINT, ( + f"Vector3 fingerprint mismatch: got 0x{fingerprint:016X}, " + f"expected 0x{VECTOR3_FINGERPRINT:016X}" + ) + + +def test_twist_fingerprint() -> None: + """Python Twist encoding starts with the same fingerprint as Rust.""" + t = Twist(linear=Vector3(0, 0, 0), angular=Vector3(0, 0, 0)) + encoded = t.lcm_encode() + fingerprint = struct.unpack(">Q", encoded[:8])[0] + assert fingerprint == TWIST_FINGERPRINT, ( + f"Twist fingerprint mismatch: got 0x{fingerprint:016X}, expected 0x{TWIST_FINGERPRINT:016X}" + ) + + +def test_vector3_known_binary_layout() -> None: + """Python Vector3(1.5, 2.5, 3.5) produces exact same bytes as Rust.""" + v = Vector3(1.5, 2.5, 3.5) + encoded = v.lcm_encode() + + # 8-byte fingerprint + 3x8-byte f64 = 32 bytes + assert len(encoded) == 32 + + # Fingerprint + assert encoded[:8] == struct.pack(">Q", VECTOR3_FINGERPRINT) + + # x=1.5 as f64 big-endian + assert encoded[8:16] == struct.pack(">d", 1.5) + + # y=2.5 as f64 big-endian + assert encoded[16:24] == struct.pack(">d", 2.5) + + # z=3.5 as f64 big-endian + assert encoded[24:32] == struct.pack(">d", 3.5) + + +def test_twist_known_binary_layout() -> None: + """Python Twist encoding matches Rust binary layout byte-for-byte.""" + t = Twist( + linear=Vector3(1.0, 2.0, 3.0), + angular=Vector3(0.1, 0.2, 0.3), + ) + encoded = t.lcm_encode() + + # 8-byte fingerprint + 6x8-byte f64 = 56 bytes + assert len(encoded) == 56 + + # Fingerprint + assert encoded[:8] == struct.pack(">Q", TWIST_FINGERPRINT) + + # linear.x, linear.y, linear.z + assert encoded[8:16] == struct.pack(">d", 1.0) + assert encoded[16:24] == struct.pack(">d", 2.0) + assert encoded[24:32] == struct.pack(">d", 3.0) + + # angular.x, angular.y, angular.z + assert encoded[32:40] == struct.pack(">d", 0.1) + assert encoded[40:48] == struct.pack(">d", 0.2) + assert encoded[48:56] == struct.pack(">d", 0.3) + + +def test_vector3_roundtrip_cross_language() -> None: + """Encode in Python, verify Rust would decode the same values.""" + v = Vector3(42.0, -17.5, 0.001) + encoded = v.lcm_encode() + decoded = Vector3.lcm_decode(encoded) + assert decoded.x == v.x + assert decoded.y == v.y + assert decoded.z == v.z + + +def test_twist_roundtrip_cross_language() -> None: + """Encode in Python, verify Rust would decode the same values.""" + t = Twist(linear=Vector3(1.5, 2.5, 3.5), angular=Vector3(0.1, 0.2, 0.3)) + encoded = t.lcm_encode() + decoded = Twist.lcm_decode(encoded) + assert decoded.linear == t.linear + assert decoded.angular == t.angular