diff --git a/.github/workflows/readme-sync.yml b/.github/workflows/readme-sync.yml new file mode 100644 index 0000000..76580e8 --- /dev/null +++ b/.github/workflows/readme-sync.yml @@ -0,0 +1,42 @@ +name: Check README Translations + +on: + pull_request: + paths: + - "README*.md" + - "tools/check_readme_sync.py" + - "tools/tests/**" + - ".github/workflows/readme-sync.yml" + + workflow_dispatch: + + +permissions: + contents: read + + +concurrency: + group: readme-sync-${{ github.ref }} + cancel-in-progress: true + + +jobs: + readme-sync: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + # Compares the translations against README.md on roadmap row counts, + # per-row status, and specification numbers. Prose is never compared. + - name: Check translations against README.md + run: python tools/check_readme_sync.py + + - name: Test the checker itself + run: python -m unittest discover -s tools/tests -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f69a6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Python bytecode from tools/ +__pycache__/ +*.py[cod] diff --git a/README_Fr.md b/README_Fr.md index 6c8a3c9..76f848e 100644 --- a/README_Fr.md +++ b/README_Fr.md @@ -156,14 +156,14 @@ Nous nous engageons à maintenir et à adapter en continu les principaux écosys | Écosystème pris en charge | Statut | Description / Date de sortie estimée | Documentation associée | | :------------------------------------------------------------------------------------- | :-------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Utilisation de base des moteurs** | ✅ Terminé | Contrôle de mouvement de base et encapsulation des API | [Robstride](https://wiki.seeedstudio.com/cn/robstride_control/) | -| **Publication en open source des nouvelles pièces structurelles 3D STEP et de la BOM** | ✅ Terminé | Fichiers STEP de toutes les pièces de la nouvelle version, nomenclature des pièces (BOM) et prix de référence pour tous les composants usinés | [BOM reBot Arm B601-RS](./hardware/reBot_B601_RS/readme.md) | +| **Publication en open source des nouvelles pièces structurelles 3D STEP et de la BOM** | ✅ Terminé | Fichiers STEP de toutes les pièces de la nouvelle version, nomenclature des pièces (BOM) et prix de référence pour tous les composants usinés | [BOM reBot Arm B601-RS](./hardware/reBot_B601_RS/README.md) | | **Prise en main (Getting Started)** | ✅ Terminé | Guide de démarrage rapide du B601-RS | [Getting Started with reBot Arm B601-RS](https://wiki.seeedstudio.com/rebot_b601_rs_getting_started/) | | **Vidéo d’assemblage** | ✅ Terminé | Étapes d’assemblage ultra détaillées avec vidéo explicative | [Vidéo d’assemblage reBot Arm B601-RS](https://wiki.seeedstudio.com/rebot_b601_rs_getting_started/) | | **ROS2 (Humble)** | ✅ Terminé | Contrôleur reBot Arm intégré à ROS2 avec prise en charge de la cinématique, de la planification de trajectoire, de la compensation de gravité et de MoveIt2 | [Guide d’intégration ROS2 du reBot Arm B601-DM](https://wiki.seeedstudio.com/rebot_arm_b601_rs_ros2_integration/) | | **Intégration LeRobot** | ✅ Terminé | Adaptation au framework d’entraînement Hugging Face LeRobot | [Guide de démarrage avec LeRobot pour le reBot Arm](https://wiki.seeedstudio.com/rebot_arm_b601_rs_lerobot/) | | **Intégration Pinocchio** | ✅ Terminé | Adaptation au framework Pinocchio permettant la cinématique directe, la cinématique inverse et la compensation de gravité du bras robotique | [Guide de démarrage Pinocchio pour reBot Arm B601-DM](https://wiki.seeedstudio.com/rebot_arm_b601_rs_pinocchio_meshcat/) et [Dépôt Github](https://github.com/vectorBH6/reBotArm_control_py) | | **Intégration caméra de profondeur** | ✅ Terminé | Démonstration de préhension visuelle basée sur YOLO et une caméra de profondeur | [Guide de démarrage de la démonstration de préhension visuelle](https://wiki.seeedstudio.com/rebot_arm_b601_dm_grasping_demo/) | -| **Simulation Isaac Sim** | ⏳ Prévu | Importation des modèles USD et activation de la téléopération simulée | [Dépôt Github](https://github.com/Seeed-Projects/reBot-Isaacsim) | +| **Simulation Isaac Sim** | ✅ Terminé | Importation des modèles USD et activation de la téléopération simulée | [Dépôt Github](https://github.com/Seeed-Projects/reBot-Isaacsim) | | **Mises à jour progressives des derniers algorithmes** | ⏳ Prévu | Les algorithmes couramment utilisés dans l’industrie seront progressivement intégrés | En cours | | **Lancement d’une série de cours entièrement gratuits** | ⏳ Prévu | Des cours gratuits couvrant les principaux algorithmes seront progressivement publiés | En cours | diff --git a/README_JP.md b/README_JP.md index 7a6a3ef..3a83738 100644 --- a/README_JP.md +++ b/README_JP.md @@ -152,19 +152,21 @@ | :--- | :---: | :--- | :--- | | **ROS2 (Humble), third_party 統合, URDF / rebotarm_bringup** | [@danieldoradotalaveron-rb](https://github.com/danieldoradotalaveron-rb) | 1. **パッシブ診断モニター** (`rebotarm_monitor_ros2`): `rqt_robot_monitor` 向け `/diagnostics` オーバーレイ、serial/CAN 対応アグリゲーター;
2. **安全な退避とシャットダウン**: 接続時に休止姿勢を取得し、シャットダウンまたは `/rebotarm/park` 時にゆっくり戻して急落を防止;
3. **重力補償 (スムーズ停止)**: 重力補償終了時に MIT ランプアウトを行い、pos/vel 引き継ぎ時の衝撃、急動作、不安定性を低減;
4. **IK/FK と安全対策を備えたゲームパッドテレオペ**: IK によるエンドエフェクタ制御、RViz でのロボット状態のライブ可視化 (シミュレーションのみでテスト);
5. **D405 eye-in-hand TF**: `rebotarm_bringup` の `end_link` 下に Xacro 設定を追加し、RViz 可視化と TF のみに使用 (driver/depth/intrinsics なし)。取付姿勢は launch ファイルで調整可能、ブラケットキャリブレーションは未完了。Teleop FK/IK はアームのみの `fixend_core` URDF を使用し、RSP/RViz には完全な xacro を使用。 | [rebotarm_monitor_ros2](https://github.com/danieldoradotalaveron-rb/rebotarm_monitor_ros2)、[reBotArmController_ROS2](https://github.com/danieldoradotalaveron-rb/reBotArmController_ROS2) | -| Écosystème pris en charge | Statut | Description / Date de sortie estimée | Documentation associée | -| :------------------------------------------------------------------------------------- | :-------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Utilisation de base des moteurs** | ✅ Terminé | Contrôle de mouvement de base et encapsulation des API | [Robstride](https://wiki.seeedstudio.com/cn/robstride_control/) | -| **Publication en open source des nouvelles pièces structurelles 3D STEP et de la BOM** | ✅ Terminé | Fichiers STEP de toutes les pièces de la nouvelle version, nomenclature des pièces (BOM) et prix de référence pour tous les composants usinés | [BOM reBot Arm B601-RS](./hardware/reBot_B601_RS/readme.md) | -| **Prise en main (Getting Started)** | ✅ Terminé | Guide de démarrage rapide du B601-RS | [Getting Started with reBot Arm B601-RS](https://wiki.seeedstudio.com/rebot_b601_rs_getting_started/) | -| **Vidéo d’assemblage** | ✅ Terminé | Étapes d’assemblage ultra détaillées avec vidéo explicative | [Vidéo d’assemblage reBot Arm B601-RS](https://wiki.seeedstudio.com/rebot_b601_rs_getting_started/) | -| **ROS2 (Humble)** | ✅ Terminé | Contrôleur reBot Arm intégré à ROS2 avec prise en charge de la cinématique, de la planification de trajectoire, de la compensation de gravité et de MoveIt2 | [Guide d’intégration ROS2 du reBot Arm B601-DM](https://wiki.seeedstudio.com/rebot_arm_b601_rs_ros2_integration/) | -| **Intégration LeRobot** | ✅ Terminé | Adaptation au framework d’entraînement Hugging Face LeRobot | [Guide de démarrage avec LeRobot pour le reBot Arm](https://wiki.seeedstudio.com/rebot_arm_b601_rs_lerobot/) | -| **Intégration Pinocchio** | ✅ Terminé | Adaptation au framework Pinocchio permettant la cinématique directe, la cinématique inverse et la compensation de gravité du bras robotique | [Guide de démarrage Pinocchio pour reBot Arm B601-DM](https://wiki.seeedstudio.com/rebot_arm_b601_rs_pinocchio_meshcat/) et [Dépôt Github](https://github.com/vectorBH6/reBotArm_control_py) | -| **Intégration caméra de profondeur** | ✅ Terminé | Démonstration de préhension visuelle basée sur YOLO et une caméra de profondeur | [Guide de démarrage de la démonstration de préhension visuelle](https://wiki.seeedstudio.com/rebot_arm_b601_dm_grasping_demo/) | -| **Simulation Isaac Sim** | ⏳ Prévu | Importation des modèles USD et activation de la téléopération simulée | [Dépôt Github](https://github.com/Seeed-Projects/reBot-Isaacsim) | -| **Mises à jour progressives des derniers algorithmes** | ⏳ Prévu | Les algorithmes couramment utilisés dans l’industrie seront progressivement intégrés | En cours | -| **Lancement d’une série de cours entièrement gratuits** | ⏳ Prévu | Des cours gratuits couvrant les principaux algorithmes seront progressivement publiés | En cours | +### reBot Arm B601 RS + +| 対応エコシステム | 状態 | 説明 / 予定公開日 | 関連ドキュメント | +| :--- | :---: | :--- | :--- | +| **モーター基本使用** | ✅ 完了 | 基本的なモーション制御と API ラッパー化 | [Robstride](https://wiki.seeedstudio.com/cn/robstride_control/) | +| **新バージョン STEP 3D 構造部品および BOM のオープンソース化** | ✅ 完了 | 新バージョンの全パーツの STEP ファイル、部品 BOM、およびすべての機械加工部品の参考価格 | [reBot Arm B601-RS BOM](./hardware/reBot_B601_RS/README.md) | +| **クイックスタート** | ✅ 完了 | B601-RS のクイックスタート | [Getting Started with reBot Arm B601-RS](https://wiki.seeedstudio.com/rebot_b601_rs_getting_started/) | +| **組み立て動画** | ✅ 完了 | 超詳細な組み立て手順と動画 | [reBot Arm B601-RS Assembly Video](https://wiki.seeedstudio.com/rebot_b601_rs_getting_started/) | +| **ROS2 (Humble)** | ✅ 完了 | 運動学、軌道計画、重力補償および MoveIt2 に対応した ROS2 統合 reBot Arm コントローラー | [reBot Arm B601-RS ROS2 Integration Guide](https://wiki.seeedstudio.com/rebot_arm_b601_rs_ros2_integration/) | +| **LeRobot 統合** | ✅ 完了 | Hugging Face LeRobot トレーニングフレームワークへの対応 | [Getting Started with LeRobot-based reBot Arm](https://wiki.seeedstudio.com/rebot_arm_b601_rs_lerobot/) | +| **Pinocchio 統合** | ✅ 完了 | Pinocchio フレームワークへの対応により、ロボットアームの順運動学/逆運動学および重力補償を実現 | [Getting Started with Pinocchio for reBot Arm B601-RS](https://wiki.seeedstudio.com/rebot_arm_b601_rs_pinocchio_meshcat/) と [Github repo](https://github.com/vectorBH6/reBotArm_control_py) | +| **深度カメラ統合** | ✅ 完了 | YOLO と深度カメラに基づくビジュアル把持デモ | [Getting Started with Visual Grasping Demo](https://wiki.seeedstudio.com/rebot_arm_b601_dm_grasping_demo/) | +| **Isaac Sim シミュレーション** | ✅ 完了 | USD モデルをインポートし、シミュレーションによる遠隔操作を実現 | [Github Repo](https://github.com/Seeed-Projects/reBot-Isaacsim) | +| **最新アルゴリズムの段階的更新** | ⏳ 計画中 | 主流アルゴリズムを段階的に更新予定 | Ongoing | +| **完全無料コースシリーズの提供** | ⏳ 計画中 | 主流アルゴリズムを段階的に更新予定 | Ongoing | --- diff --git a/README_zh.md b/README_zh.md index a6347d7..10618df 100644 --- a/README_zh.md +++ b/README_zh.md @@ -131,6 +131,7 @@ reBot机械臂RS版本在[矽递科技电商平台](https://detail.tmall.com/ite | **Isaac Sim 仿真** | 🚧 进行中 | 导入USD模型并实现仿真遥操作 | [由于添加额外课程延迟: 2026.06.20]| | **LeRobot 适配** | ✅ 完成 | 适配 Hugging Face LeRobot 训练框架 | [reBot Arm B601-DM Lerobot使用教程](https://wiki.seeedstudio.com/cn/rebot_arm_b601_dm_lerobot/)| | **深度相机集成** | ✅ 完成 | 基于 YOLO 与深度相机的视觉夹取演示 | [reBot Arm B601-DM 视觉夹取 Demo](https://wiki.seeedstudio.com/cn/rebot_arm_b601_dm_grasping_demo/) | +| **reSpeaker 语音集成** | ✅ 完成 | 加入 reSpeaker Flex 4 麦克风阵列,构建具备空间感知能力的语音驱动智能机械臂控制系统 | [reBot Arm B601-DM 语音控制](https://wiki.seeedstudio.com/cn/control_rebot_arm_using_voice_with_respeaker_flex/) | | **逐步更新最新算法** | ⏳ 计划中 | 逐步更新主流算法 | 持续进行 | | **推出系列完全免费课程** | ⏳ 计划中 | 逐步更新主流算法 | 持续进行 | @@ -147,7 +148,7 @@ reBot机械臂RS版本在[矽递科技电商平台](https://detail.tmall.com/ite | **LeRobot 适配** | ✅ 完成 | 适配 Hugging Face LeRobot 训练框架 | [reBot Arm B601-RS入门Lerobot](https://wiki.seeedstudio.com/cn/rebot_arm_b601_rs_lerobot/)| | **Pinocchio 适配** | ✅ 完成 | 适配 Pinocchio框架、实现机械臂正逆运动学及动力学重力补偿功能 |[reBot Arm B601-DM 的 Pinocchio 与 MeshCat 入门指南](https://wiki.seeedstudio.com/cn/rebot_arm_b601_rs_pinocchio_meshcat/) 以及 [Github 控制代码](https://github.com/vectorBH6/reBotArm_control_py)| | **深度相机集成** | ✅ 完成 | 基于 YOLO 与深度相机的视觉夹取演示 | [reBot Arm B601-RS 视觉夹取 Demo](https://wiki.seeedstudio.com/cn/rebot_arm_b601_rs_grasping_demo/) | -| **Isaac Sim 仿真** | ⏳ 计划中 | 导入USD模型并实现仿真遥操作 | [源代码](https://github.com/Seeed-Projects/reBot-Isaacsim)| +| **Isaac Sim 仿真** | ✅ 完成 | 导入USD模型并实现仿真遥操作 | [源代码](https://github.com/Seeed-Projects/reBot-Isaacsim)| | **逐步更新最新算法** | ⏳ 计划中 | 逐步更新主流算法 | 持续进行 | | **推出系列完全免费课程** | ⏳ 计划中 | 逐步更新主流算法 | 持续进行 | @@ -163,7 +164,7 @@ reBot-DevArm 专为桌面级具身智能应用设计,兼顾了负载能力与 | :--- | :--- | :--- | | **工作负载 (Payload)** | 1.5kg | **2.5kg** | | **推荐工作空间** | 70%臂展工作空间 | 70%臂展工作空间 | -| **最大臂展 (Reach)** | 650 mm | **754 mm** | +| **最大臂展 (Reach)** | 767 mm | **754 mm** | | **自重 (Weight)** | **约 4.5 kg** |约 6.7 kg | | **重复定位精度** | < 0.2 mm | < 0.2 mm | | **自由度 (DOF)** | 6 DOF + 1 夹爪|6 DOF + 1 夹爪| diff --git a/tools/check_readme_sync.py b/tools/check_readme_sync.py new file mode 100644 index 0000000..1207c50 --- /dev/null +++ b/tools/check_readme_sync.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Check the translated READMEs against the English one. + +The translations drift: a row gets added to README.md, a status flips from +planned to completed, a specification is corrected — and the other four files +keep the old version until a reader notices. This checks the things that must +not differ between translations and deliberately ignores the things that must: + + * how many roadmap tables there are, + * how many rows each of those tables has, + * the status glyph (OK / in progress / planned) on each row, + * the numbers in the hardware specification table. + +Prose is never compared, so translators stay free to phrase things their own +way. Run it from the repository root: + + python tools/check_readme_sync.py + +Exits 0 when the files agree, 1 when they do not. +""" +from __future__ import annotations + +import argparse +import os +import re +import sys + +SOURCE = "README.md" +TRANSLATIONS = ["README_zh.md", "README_JP.md", "README_Fr.md", "README_es.md"] + +STATUS_GLYPHS = "\u2705\U0001F6A7\u23F3" # done / in progress / planned +STATUS_RE = re.compile(f"[{STATUS_GLYPHS}]") + +# A measurement in the specification table: "767 mm", "1.5kg", "DC 24V", "< 0.2 mm". +# Decimal separators differ by locale (1.5 kg vs 1,5 kg), so they are normalised. +NUMBER_RE = re.compile(r"\d+(?:[.,]\d+)?") + + +def read_lines(path): + with open(path, encoding="utf-8") as handle: + return handle.read().splitlines() + + +def split_tables(lines): + """Group consecutive pipe-table lines, keeping only tables with statuses.""" + tables, current = [], [] + for line in lines: + if line.strip().startswith("|"): + current.append(line) + elif current: + tables.append(current) + current = [] + if current: + tables.append(current) + return [t for t in tables if any(STATUS_RE.search(line) for line in t)] + + +def status_rows(table): + """The status glyphs of each row, in order.""" + rows = [] + for line in table: + if not STATUS_RE.search(line): + continue + rows.append("".join(STATUS_RE.findall(line))) + return rows + + +def spec_numbers(lines): + """Numbers from the hardware specification table, row by row. + + Identified by the row label mentioning a parameter the table always + carries; matching on position instead would break the moment a translation + reorders a row, which is allowed. + """ + out = {} + for line in lines: + if not line.strip().startswith("|"): + continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 3: + continue + label = cells[0] + key = None + for marker, name in ( + (r"Reach|臂展|リーチ|Portée|Alcance", "reach"), + (r"Payload|负载|負荷|Charge utile|Carga", "payload"), + (r"Weight|自重|Poids|Peso", "weight"), + (r"Voltage|电压|電圧|Tension|Tensión", "voltage"), + (r"Repeatability|重复定位|繰り返し|Répétabilité|Repetibilidad", "repeatability"), + ): + if re.search(marker, label, re.I): + key = name + break + if key is None or key in out: + continue + values = [] + for cell in cells[1:3]: + values.append([n.replace(",", ".") for n in NUMBER_RE.findall(cell)]) + out[key] = values + return out + + +def check(root): + source_lines = read_lines(os.path.join(root, SOURCE)) + source_tables = split_tables(source_lines) + source_specs = spec_numbers(source_lines) + problems = [] + + for name in TRANSLATIONS: + path = os.path.join(root, name) + if not os.path.exists(path): + problems.append(f"{name}: file is missing") + continue + lines = read_lines(path) + tables = split_tables(lines) + + if len(tables) != len(source_tables): + problems.append( + f"{name}: has {len(tables)} roadmap tables, {SOURCE} has " + f"{len(source_tables)}" + ) + + for index, (src, dst) in enumerate(zip(source_tables, tables), start=1): + src_rows, dst_rows = status_rows(src), status_rows(dst) + if len(src_rows) != len(dst_rows): + problems.append( + f"{name}: roadmap table {index} has {len(dst_rows)} rows, " + f"{SOURCE} has {len(src_rows)} — a row was added or removed " + f"in one and not the other" + ) + continue + for row, (a, b) in enumerate(zip(src_rows, dst_rows), start=1): + if a != b: + problems.append( + f"{name}: roadmap table {index} row {row} is marked " + f"{b!r}, {SOURCE} marks it {a!r}" + ) + + specs = spec_numbers(lines) + for key, expected in source_specs.items(): + if key not in specs: + problems.append(f"{name}: specification table has no {key} row") + continue + if specs[key] != expected: + problems.append( + f"{name}: specification {key} is {specs[key]}, " + f"{SOURCE} says {expected}" + ) + + return problems + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "root", + nargs="?", + default=".", + help="repository root (default: current directory)", + ) + args = parser.parse_args() + + problems = check(args.root) + if not problems: + print(f"{len(TRANSLATIONS)} translations agree with {SOURCE}.") + return 0 + + print(f"{len(problems)} disagreement(s) with {SOURCE}:\n") + for problem in problems: + print(f" {problem}") + print( + "\nUpdate the translation to match README.md, or update README.md if it " + "is the one that is wrong." + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/test_check_readme_sync.py b/tools/tests/test_check_readme_sync.py new file mode 100644 index 0000000..303fec8 --- /dev/null +++ b/tools/tests/test_check_readme_sync.py @@ -0,0 +1,139 @@ +"""Tests for tools/check_readme_sync.py. + +Run from the repository root with: + + python -m unittest discover -s tools/tests -v +""" +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import check_readme_sync as sync # noqa: E402 + + +SOURCE = """\ +### reBot Arm B601 DM + +| Ecosystem | Status | Notes | +| :--- | :---: | :--- | +| **Motors** | \u2705 Completed | basic control | +| **ROS2** | \u2705 Completed | controller | +| **Isaac Sim** | \u2705 Completed | USD models | +| **Courses** | \u23F3 Planned | later | + +## Specifications + +| Parameter | B601-DM | B601-RS | +| :--- | :--- | :--- | +| **Payload** | 1.5kg | 2.5kg | +| **Max Reach** | 767 mm | 754 mm | +| **Supply Voltage** | DC 24V | DC 48V | +""" + +# Same facts, different language and phrasing, comma decimal separator. +GOOD_TRANSLATION = """\ +### reBot Arm B601 DM + +| \u00c9cosyst\u00e8me | Statut | Notes | +| :--- | :---: | :--- | +| **Moteurs** | \u2705 Termin\u00e9 | contr\u00f4le de base | +| **ROS2** | \u2705 Termin\u00e9 | contr\u00f4leur | +| **Simulation Isaac Sim** | \u2705 Termin\u00e9 | mod\u00e8les USD | +| **Cours** | \u23F3 Pr\u00e9vu | plus tard | + +## Sp\u00e9cifications + +| Param\u00e8tre | B601-DM | B601-RS | +| :--- | :--- | :--- | +| **Charge utile** | 1,5 kg | 2,5 kg | +| **Port\u00e9e maximale** | 767 mm | 754 mm | +| **Tension d'alimentation** | DC 24V | DC 48V | +""" + + +class SyncCheckTest(unittest.TestCase): + def setUp(self): + self.root = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + self.write(sync.SOURCE, SOURCE) + for name in sync.TRANSLATIONS: + self.write(name, GOOD_TRANSLATION) + + def write(self, name, text): + with open(os.path.join(self.root, name), "w", encoding="utf-8") as handle: + handle.write(text) + + def test_translations_that_agree_pass(self): + self.assertEqual(sync.check(self.root), []) + + def test_differing_decimal_separator_is_not_a_difference(self): + """1,5 kg and 1.5kg are the same payload written two ways.""" + self.assertEqual(sync.check(self.root), []) + + def test_stale_status_is_caught(self): + """The real bug: Isaac Sim shipped, a translation still says planned.""" + stale = GOOD_TRANSLATION.replace( + "| **Simulation Isaac Sim** | \u2705 Termin\u00e9 |", + "| **Simulation Isaac Sim** | \u23F3 Pr\u00e9vu |", + ) + self.write("README_Fr.md", stale) + problems = sync.check(self.root) + self.assertEqual(len(problems), 1) + self.assertIn("README_Fr.md", problems[0]) + self.assertIn("row 3", problems[0]) + + def test_missing_row_is_caught(self): + """A feature row added to English but never to a translation.""" + short = GOOD_TRANSLATION.replace( + "| **Simulation Isaac Sim** | \u2705 Termin\u00e9 | mod\u00e8les USD |\n", "" + ) + self.write("README_zh.md", short) + problems = sync.check(self.root) + self.assertTrue(any("3 rows" in p and "README_zh.md" in p for p in problems)) + + def test_superseded_specification_is_caught(self): + """The real bug: README_zh kept 650 mm after English moved to 767 mm.""" + old = GOOD_TRANSLATION.replace("| 767 mm |", "| 650 mm |") + self.write("README_zh.md", old) + problems = sync.check(self.root) + self.assertEqual(len(problems), 1) + self.assertIn("reach", problems[0]) + self.assertIn("650", problems[0]) + + def test_missing_specification_row_is_caught(self): + without = GOOD_TRANSLATION.replace( + "| **Port\u00e9e maximale** | 767 mm | 754 mm |\n", "" + ) + self.write("README_JP.md", without) + problems = sync.check(self.root) + self.assertTrue(any("no reach row" in p for p in problems)) + + def test_missing_file_is_reported_not_crashed_on(self): + os.remove(os.path.join(self.root, "README_es.md")) + problems = sync.check(self.root) + self.assertTrue(any("is missing" in p for p in problems)) + + def test_extra_table_is_caught(self): + extra = GOOD_TRANSLATION + ( + "\n| Extra | Statut |\n| :--- | :---: |\n| **Bonus** | \u2705 Termin\u00e9 |\n" + ) + self.write("README_es.md", extra) + problems = sync.check(self.root) + self.assertTrue(any("roadmap tables" in p for p in problems)) + + +class RepositoryTest(unittest.TestCase): + """The real files in this repository must agree.""" + + def test_repository_readmes_are_in_sync(self): + root = os.path.join(os.path.dirname(__file__), "..", "..") + problems = sync.check(root) + self.assertEqual(problems, [], "\n".join(problems)) + + +if __name__ == "__main__": + unittest.main()