#!/usr/bin/env python3
"""test_gamepad_teleop.py — gamepad_teleop.py の確かめ (アームなし・コントローラーなし. 依頼 68).

  python3 tools/test_gamepad_teleop.py -v

中身:
  - ボタン番号と操作の対応 (依頼 68 の表) / 十字キー上下の向き / △ で j1 → j5 → j1
  - 守り: j1 は 90.87 を超えない / 物理可動域まで (基準の姿勢からの枠はない. 2026-11-03) / J4_SAFE
  - 長押し (2026-11-03): 短い押しは 1 刻み / 押している間は送り続ける / 離す・停止・△・範囲の端で止まる /
    指令が読んだ角度より 3° 先なら送らずに待つ / 追いつかなければ止める
  - 停止: L2・R2 の両方 > 0.0 で止まる・片方では止まらない / 停止中は受け付けない / OPTIONS で戻る / 接続が切れたら停止
  - HOME: 1 秒に満たない長押しでは送らない / 1 秒続けたら 1 回だけ送る
  - 動いている間の押しは受け付けない (ためない)
  - daemon への送り方: 停止が立っていれば送らない / 届かなくても送り直さない
  - ドライランで main() がひととおり動く (偽のコントローラー・ダミーの画面・一時フォルダ)
  - 依頼 69: ドライランは KIOXIA に書かない (フォルダも作らない・KIOXIA がなくても動く) / --execute は今までどおり書く /
    ドライランでは cv2 (2 つ目の libSDL2) を読まない・objc の重複の警告が出ない (別のプロセスで確かめる)
  - codex R1 の指摘: 範囲外の出発点 / 目標と読んだ角度のずれ / 長押しの持ち越し / 停止と送信の順番 /
    再開で古い仕事が生き返らない / 読み取り失敗 / 記録の失敗 / ボタンより先に L2+R2 を見る
KIOXIA にもアームにも触らない.
"""
import json
import os
import subprocess
import sys
import tempfile
import threading
import types
import time
import unittest
from pathlib import Path
from unittest import mock

os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")

import numpy as np  # noqa: E402

sys.path.insert(0, str(Path(__file__).resolve().parent))
import gamepad_teleop as gt  # noqa: E402

# DaemonArm のテストで daemon の送り口 (の差し替え) を使うので読み込んでおく.
# ドライランで読まないことは TestNoCv2InDryRun で別のプロセスで確かめる
gt.load_daemon_modules()

HOME = [0.0, 0.0, 0.0, 0.0, 0.0, -30.0]

# テストは 0.5° の刻みで書いてある. 実際の値 (1°. 2026-11-03) は TestStepValue で確かめる
STEP_FINE_REAL = gt.STEP_FINE_DEG
gt.STEP_FINE_DEG = 0.5


class TestStepValue(unittest.TestCase):
    def test_real_steps(self):
        self.assertEqual(STEP_FINE_REAL, 1.0)
        self.assertEqual(gt.STEP_COARSE_DEG, 2.0)


class ListLog:
    def __init__(self):
        self.rows = []

    def write(self, rec):
        json.dumps(rec, ensure_ascii=False)      # JSON にできること
        self.rows.append(rec)


class FakeArm:
    execute = False

    def __init__(self, start=HOME, reach=True):
        self.q = np.asarray(start, dtype=float).copy()
        self.sent = []
        self.grips = []
        self.reach = reach

    def read(self):
        return self.q.copy()

    def send_and_check(self, target, gate, quitting, check=True, joint=None):
        if not gate.run(lambda: self.sent.append([float(v) for v in target])):
            return {"sent": False, "why": "halted_before_send"}
        if not check:
            self.checks = getattr(self, "checks", []) + [False]
            self.q = np.asarray(target, dtype=float).copy() if self.reach else self.q
            return {"sent": True, "reached": None, "no_check": True}
        if self.reach:
            self.q = np.asarray(target, dtype=float).copy()
        return {"sent": True, "reached": self.reach, "reached_angles": gt._r(self.q), "reach_err": 0.0}

    def gripper(self, state, gate):
        return gate.run(lambda: self.grips.append(state))

    def halt(self):
        self.halts = getattr(self, "halts", 0) + 1
        return {"halt": "stop", "measured_after_halt": gt._r(self.q)}


def make(start=HOME, reach=True, sync=True):
    arm = FakeArm(start, reach)
    log = ListLog()
    spawned = []
    spawn = (lambda fn: fn()) if sync else spawned.append
    tp = gt.Teleop(arm, start, log, out=lambda *a: None, spawn=spawn)
    return tp, arm, log, spawned


def press(tp, b, now=0.0):
    tp.press(b, now)
    tp.release(b, now)
    tp.poll()


class TestButtonMap(unittest.TestCase):
    def test_numbers(self):
        self.assertEqual((gt.BTN_CROSS, gt.BTN_CIRCLE, gt.BTN_SQUARE, gt.BTN_TRIANGLE), (0, 1, 2, 3))
        self.assertEqual((gt.BTN_SHARE, gt.BTN_HOME, gt.BTN_OPTIONS, gt.BTN_L3, gt.BTN_R3), (4, 5, 6, 7, 8))
        self.assertEqual((gt.BTN_L1, gt.BTN_R1), (9, 10))
        self.assertEqual((gt.BTN_UP, gt.BTN_DOWN, gt.BTN_LEFT, gt.BTN_RIGHT, gt.BTN_TOUCHPAD), (11, 12, 13, 14, 15))
        self.assertEqual((gt.AXIS_L2, gt.AXIS_R2), (4, 5))

    def test_dpad_left_right_always_j0(self):
        for sel in range(5):
            tp, arm, log, _ = make()
            tp.sel = sel
            press(tp, gt.BTN_RIGHT)
            self.assertEqual(log.rows[-1]["joint"], 0)
            self.assertAlmostEqual(arm.sent[-1][0], 0.5)
            press(tp, gt.BTN_LEFT)
            press(tp, gt.BTN_LEFT)
            self.assertAlmostEqual(arm.sent[-1][0], -0.5)
            self.assertEqual(arm.sent[-1][1:], HOME[1:])

    def test_up_minus_down_plus_starts_j1(self):
        tp, arm, log, _ = make()
        self.assertEqual(tp.joint, 1)
        press(tp, gt.BTN_DOWN)
        self.assertAlmostEqual(arm.sent[-1][1], 0.5)
        press(tp, gt.BTN_UP)
        press(tp, gt.BTN_UP)
        self.assertAlmostEqual(arm.sent[-1][1], -0.5)

    def test_triangle_cycles(self):
        tp, arm, log, _ = make()
        seen = [tp.joint]
        for _ in range(5):
            press(tp, gt.BTN_TRIANGLE)
            seen.append(tp.joint)
        self.assertEqual(seen, [1, 2, 3, 4, 5, 1])
        self.assertEqual(arm.sent, [])                      # 切り替えだけでは送らない
        tp.sel = 3                                           # j4
        press(tp, gt.BTN_DOWN)
        self.assertAlmostEqual(arm.sent[-1][4], 0.5)

    def test_r1_coarse_step(self):
        tp, arm, log, _ = make()
        tp.press(gt.BTN_R1, 0.0)
        press(tp, gt.BTN_RIGHT)
        self.assertAlmostEqual(arm.sent[-1][0], 2.0)
        press(tp, gt.BTN_DOWN)
        self.assertAlmostEqual(arm.sent[-1][1], 2.0)
        tp.release(gt.BTN_R1, 0.0)
        press(tp, gt.BTN_RIGHT)
        self.assertAlmostEqual(arm.sent[-1][0], 2.5)

    def test_gripper(self):
        tp, arm, log, _ = make()
        press(tp, gt.BTN_CROSS)
        self.assertEqual(arm.grips, [1])
        self.assertEqual(tp.grip, "closed")
        press(tp, gt.BTN_CIRCLE)
        self.assertEqual(arm.grips, [1, 0])
        self.assertEqual(tp.grip, "open")

    def test_unassigned_do_nothing(self):
        tp, arm, log, _ = make()
        for b in (gt.BTN_SQUARE, gt.BTN_SHARE, gt.BTN_L1, gt.BTN_L3, gt.BTN_R3, gt.BTN_TOUCHPAD):
            press(tp, b)
        self.assertEqual(arm.sent, [])
        self.assertEqual(arm.grips, [])
        self.assertTrue(all(r["kind"] == "ignored" for r in log.rows))
        self.assertEqual(tp.joint, 1)


class TestGuards(unittest.TestCase):
    def test_j1_never_above_9087(self):
        start = [0.0, 85.0, 0.0, 0.0, 0.0, -30.0]
        tp, arm, log, _ = make(start)
        tp.r1 = True
        for _ in range(6):
            press(tp, gt.BTN_DOWN)
        self.assertTrue(all(s[1] <= 90.87 + 1e-9 for s in arm.sent))
        self.assertAlmostEqual(arm.sent[-1][1], 90.87)
        clipped = [r for r in log.rows if r.get("clip")]
        self.assertEqual(clipped[0]["clip"][0]["limit"], "J1_MAX")
        refused = [r for r in log.rows if r.get("refused")]
        self.assertTrue(refused and refused[-1]["refused"][0]["why"] == "at_limit")
        self.assertIn("J1_MAX", tp.last_limit)

    def test_home_to_pick_height(self):
        # HOME から j1 を PICK の高さ 90.87 まで下ろせる (基準の姿勢からの ±10° の枠はない. 2026-11-03)
        tp, arm, log, _ = make()
        tp.r1 = True
        for _ in range(50):
            press(tp, gt.BTN_DOWN)
        self.assertAlmostEqual(max(s[1] for s in arm.sent), 90.87)
        self.assertFalse(any(r.get("clip") and r["clip"][0]["limit"] == "BASE" for r in log.rows))
        # ほかの関節も ±10° を超えて動く
        for _ in range(12):
            press(tp, gt.BTN_RIGHT)
        self.assertAlmostEqual(arm.sent[-1][0], 24.0)
        tp.sel = 1                                          # j2
        for _ in range(15):
            press(tp, gt.BTN_UP)
        self.assertAlmostEqual(arm.sent[-1][2], -30.0)
        tp.sel = 4                                          # j5
        for _ in range(10):
            press(tp, gt.BTN_DOWN)
        self.assertAlmostEqual(arm.sent[-1][5], -10.0)

    def test_j4_safe(self):
        start = [0.0, 0.0, 0.0, 0.0, 55.0, -30.0]
        tp, arm, log, _ = make(start)
        tp.sel = 3
        tp.r1 = True
        for _ in range(5):
            press(tp, gt.BTN_DOWN)
        self.assertAlmostEqual(max(s[4] for s in arm.sent), 60.0)
        self.assertEqual([r for r in log.rows if r.get("clip")][0]["clip"][0]["limit"], "J4_SAFE")

    def test_window_function(self):
        lo, hi, lo_src, hi_src = gt.joint_window()
        self.assertAlmostEqual(hi[1], 90.87)
        self.assertEqual(hi_src[1], "J1_MAX")
        self.assertAlmostEqual(lo[4], -90.0)
        self.assertAlmostEqual(hi[4], 60.0)
        self.assertEqual((lo_src[4], hi_src[4]), ("J4_SAFE", "J4_SAFE"))
        for k in (0, 2, 3, 5):                                # 残りは物理可動域
            self.assertEqual((lo[k], hi[k]), gt.JOINT_LIMITS[k])
            self.assertEqual((lo_src[k], hi_src[k]), ("PHYS", "PHYS"))
        self.assertEqual(lo[1], gt.JOINT_LIMITS[1][0])

    def test_no_compensation_other_joints(self):
        start = [0.0, 0.0, 0.0, 0.0, 0.0, -30.0]
        p = gt.plan_step(start, 1, 20.0)
        self.assertAlmostEqual(p["target"][1], 20.0)
        self.assertEqual(list(p["target"][[0, 2, 3, 4, 5]]), [0.0, 0.0, 0.0, 0.0, -30.0])

    def test_out_of_window_start_refuses(self):
        # 起動したときに j1 が 91 (守りの外): j1 を下げる (−) 押しも, 他の関節の押しも送らない
        start = [0.0, 91.0, 0.0, 0.0, 0.0, -30.0]
        p = gt.plan_step(start, 0, 0.5)
        self.assertIsNone(p["target"])
        self.assertEqual(p["refused"][0]["joint"], 1)

    def test_home_then_continue(self):
        start = [10.0, 5.0, 0.0, 0.0, 0.0, -30.0]
        tp, arm, log, _ = make(start)
        tp.press(gt.BTN_HOME, 0.0)
        tp.tick(1.01)
        tp.poll()
        self.assertEqual(arm.sent[-1], HOME)
        tp.r1 = True
        for _ in range(10):
            press(tp, gt.BTN_LEFT)
        self.assertAlmostEqual(arm.sent[-1][0], -20.0)       # HOME から続けて動く (枠はない)

    def test_home_bypasses_range(self):
        # 範囲の外 (j1=95) にいても HOME は送れる
        start = [0.0, 95.0, 0.0, 0.0, 0.0, -30.0]
        tp, arm, log, _ = make(start)
        press(tp, gt.BTN_DOWN)
        self.assertEqual(arm.sent, [])
        tp.press(gt.BTN_HOME, 0.0)
        tp.tick(1.0)
        tp.poll()
        self.assertEqual(arm.sent, [HOME])

    def test_start_from_measured_when_not_reached(self):
        tp, arm, log, _ = make(reach=False)
        press(tp, gt.BTN_RIGHT)                    # 届かない (仮の姿勢は HOME のまま)
        self.assertFalse(tp.cmd_reached)
        press(tp, gt.BTN_RIGHT)                    # 次は読んだ角度 (0) から
        self.assertAlmostEqual(arm.sent[-1][0], 0.5)
        self.assertEqual(log.rows[-1]["start_source"], "measured")


class LagArm(FakeArm):
    """送ると「届いた」と返すが, 角度は読むたびに lag_step ずつしか追いつかない (読みが古いのと同じ)."""

    def __init__(self, start=HOME, lag_step=0.0):
        super().__init__(start)
        self.target = np.asarray(start, dtype=float).copy()
        self.lag_step = lag_step
        self.reads = 0

    def read(self):
        self.reads += 1
        d = self.target - self.q
        self.q = self.q + np.clip(d, -self.lag_step, self.lag_step)
        return self.q.copy()

    def send_and_check(self, target, gate, quitting, check=True, joint=None):
        if not gate.run(lambda: self.sent.append([float(v) for v in target])):
            return {"sent": False, "why": "halted_before_send"}
        self.target = np.asarray(target, dtype=float).copy()
        if not check:
            return {"sent": True, "reached": None, "no_check": True}
        return {"sent": True, "reached": True, "reached_angles": gt._r(self.q), "reach_err": 0.0}

    def halt(self):
        self.halts = getattr(self, "halts", 0) + 1
        self.target = self.q.copy()                   # stop: その場で止まる
        return {"halt": "stop", "measured_after_halt": gt._r(self.q)}


def make_arm(arm, sync=True):
    log = ListLog()
    out = []
    tp = gt.Teleop(arm, arm.q.copy(), log, out=out.append, spawn=lambda fn: fn())
    return tp, log, out


class TestHold(unittest.TestCase):
    """長押し (2026-11-05 作り直し): 0.4 秒で「読んだ角度 + 15° 先」を 1 回送り, 0.1 秒おきに読んで,
    残り 5° 以下になったら次の 15° 先を送る. 届くのを待たない. 長押しが終わったら stop を送る."""

    def test_short_press_is_one_step(self):
        tp, arm, log, _ = make()
        tp.press(gt.BTN_RIGHT, 0.0)
        tp.poll()
        tp.release(gt.BTN_RIGHT, 0.1)
        for t in (0.4, 0.6, 1.0):
            tp.tick(t)
            tp.poll()
        self.assertEqual(len(arm.sent), 1)

    def test_hold_sends_ahead_of_measured_without_waiting(self):
        arm = LagArm(lag_step=1.0)                    # 読むたびに 1° ずつ目標へ寄る
        tp, log, out = make_arm(arm)
        tp.press(gt.BTN_RIGHT, 0.0); tp.poll()        # 押した瞬間には送らない (11/5)
        for t in (0.1, 0.3, 0.39):
            tp.tick(t); tp.poll()
        self.assertEqual(len(arm.sent), 0)
        t = 0.4
        for _ in range(30):
            tp.tick(t); tp.poll()
            t += 0.1
        rows = [r for r in log.rows if r["kind"] == "move" and r.get("hold_n", 0) >= 1]
        sends = [r for r in rows if r.get("sent") is True]
        waits = [r for r in rows if r.get("why") == "lead_ok"]
        self.assertGreaterEqual(len(sends), 2)        # 30 回読んで, 送ったのは数回だけ
        self.assertLessEqual(len(sends), 4)
        self.assertEqual(len(arm.sent), len(sends))   # 1 刻みは送らない (長押しだけ)
        self.assertEqual(len(sends) + len(waits), 30)
        for r in sends:                               # 動かす関節は 読んだ角度 + 15°, ほかは そのまま
            self.assertAlmostEqual(r["command"][0], r["measured_before"][0] + gt.HOLD_AHEAD_DEG, places=2)
            self.assertEqual(r["command"][1:], HOME[1:])
            self.assertTrue(r["no_check"])
        for r in waits:                               # 送らなかったのは, 残りが 5° より多いとき
            self.assertGreater(r["hold_remaining_deg"], gt.HOLD_REFILL_DEG)
        for r in sends[1:]:                           # 次を送ったのは, 残りが 5° 以下になってから
            self.assertLessEqual(r["hold_lead_deg"], gt.HOLD_REFILL_DEG + 1e-9)
        n = len(arm.sent)
        tp.release(gt.BTN_RIGHT, t)
        tp.poll()
        for t2 in (t + 0.2, t + 0.5):
            tp.tick(t2); tp.poll()
        self.assertEqual(len(arm.sent), n)            # 離したら stop だけ (送り直しはしない. 11/5 16:13)
        self.assertEqual(arm.halts, 1)                # 離したら stop を 1 回送る
        self.assertEqual(arm.sent[-1][1:], HOME[1:])
        halt = [r for r in log.rows if r["kind"] == "halt"]
        self.assertEqual(len(halt), 1)
        self.assertEqual(halt[0]["halt_reason"], "release")
        self.assertTrue(any("[長押し] j0" in o for o in out))     # 離したときに 1 行
        q = arm.q.copy()
        for _ in range(5):
            arm.read()
        self.assertTrue(np.allclose(arm.q, q))        # stop のあと動かない
        self.assertTrue(np.allclose(tp.cmd, q))       # 止まった所が次の出発点

    def test_hold_keeps_other_joints_at_command(self):
        # j1 が重さで 1° 沈んでいても, 長押しの送信で j1 を読んだ値で送り直さない (ずり下がらない)
        arm = LagArm(start=[0, 30.0, 0, 0, 0, -30], lag_step=1.0)
        tp, log, out = make_arm(arm)
        arm.q[1] = 31.0
        tp.press(gt.BTN_RIGHT, 0.0); tp.poll()
        t = 0.4
        for _ in range(5):
            tp.tick(t); tp.poll()
            t += 0.1
        self.assertTrue(all(abs(s[1] - 30.0) < 1e-9 for s in arm.sent))

    def test_hold_direction_and_selected_joint(self):
        tp, arm, log, _ = make()
        tp.sel = 1                                    # j2
        tp.press(gt.BTN_UP, 0.0); tp.poll()
        tp.tick(0.4); tp.poll()
        self.assertAlmostEqual(arm.sent[-1][2], -gt.HOLD_AHEAD_DEG)

    def test_triangle_and_other_buttons_end_hold(self):
        tp, arm, log, _ = make()
        tp.press(gt.BTN_DOWN, 0.0); tp.poll()
        tp.press(gt.BTN_TRIANGLE, 0.1)
        tp.tick(0.5); tp.poll()
        self.assertEqual(len(arm.sent), 0)
        tp.press(gt.BTN_RIGHT, 1.0); tp.poll()
        tp.press(gt.BTN_CIRCLE, 1.1); tp.poll()       # 爪で止まる
        tp.tick(1.5); tp.poll()
        self.assertEqual(len(arm.sent), 0)
        self.assertEqual(arm.grips, [0])

    def test_stop_ends_hold_and_resume_does_not_revive(self):
        tp, arm, log, _ = make()
        tp.press(gt.BTN_RIGHT, 0.0); tp.poll()
        tp.tick(0.4); tp.poll()
        tp.set_triggers(0.5, 0.5)
        tp.tick(0.6); tp.poll()
        self.assertEqual(len(arm.sent), 1)
        self.assertIsNone(tp.hold)
        tp.set_triggers(-1.0, -1.0)
        press(tp, gt.BTN_OPTIONS, 0.7)
        tp.tick(0.9); tp.tick(1.2); tp.poll()
        self.assertEqual(len(arm.sent), 1)

    def test_disconnect_ends_hold(self):
        tp, arm, log, _ = make()
        tp.press(gt.BTN_RIGHT, 0.0); tp.poll()
        tp.controller_lost("controller_disconnected")
        tp.tick(0.5); tp.poll()
        self.assertEqual(len(arm.sent), 0)
        self.assertIsNone(tp.hold)

    def test_limit_ends_hold_without_repeating_lines(self):
        start = [0.0, 80.0, 0.0, 0.0, 0.0, -30.0]
        arm = LagArm(start=start, lag_step=0.5)
        tp, log, out = make_arm(arm)
        tp.press(gt.BTN_DOWN, 0.0); tp.poll()         # 80 → 80.5
        tp.tick(0.4); tp.poll()                       # 読んだ角度 + 15 → 90.87 に切る
        self.assertAlmostEqual(arm.sent[-1][1], 90.87)
        t = 0.5
        for _ in range(60):                           # 端へ向かっている間は送らず, 端の手前 3° 以内で長押しを止める
            if tp.hold is None:
                break
            tp.tick(t); tp.poll()
            t += 0.1
        self.assertIsNone(tp.hold)
        self.assertGreater(arm.q[1], 90.87 - gt.REACH_TOL_DEG - 0.3)   # 端の 5° 手前で止めない
        n_rows = len(log.rows)
        for t2 in (t + 0.1, t + 0.5, t + 2.0):
            tp.tick(t2); tp.poll()
        self.assertEqual(len(arm.sent), 1)            # 長押しの 1 回だけ
        self.assertEqual(len(log.rows), n_rows)
        refused = [r for r in log.rows if r.get("refused")]
        self.assertEqual(refused[-1]["refused"][0]["why"], "at_limit")
        self.assertTrue(any(r.get("at_limit_target") for r in log.rows))
        self.assertEqual(arm.halts, 1)

    def test_hold_target_never_goes_back(self):
        # 読みが古く, 前の目標より後ろの「読んだ角度 + 3°」になるときは, 前の目標を送る (引き戻さない)
        arm = LagArm(lag_step=2.0)
        tp, log, out = make_arm(arm)
        tp.press(gt.BTN_RIGHT, 0.0); tp.poll()
        tp.tick(0.4); tp.poll()
        arm.lag_step = 0.0                            # 読みが進まない (古い)
        arm.q[0] = 0.0
        tp.tick(0.5); tp.poll()
        js = [s[0] for s in arm.sent]
        self.assertEqual(js, sorted(js))              # 右へ押している間, 目標は戻らない
        self.assertEqual(log.rows[-1].get("why"), "lead_ok")   # 目標へ向かっている途中なので 送らない

    def test_stall_ends_hold(self):
        arm = LagArm(lag_step=0.0)                    # まったく動かない (当たっている)
        tp, log, out = make_arm(arm)
        tp.press(gt.BTN_RIGHT, 0.0); tp.poll()
        t = 0.4
        for _ in range(20):
            tp.tick(t); tp.poll()
            t += 0.1
        self.assertIsNone(tp.hold)
        self.assertLessEqual(len(arm.sent), 1 + 12)
        self.assertTrue(any("進まないので止めました" in o for o in out))
        self.assertTrue(all(s[0] <= gt.HOLD_AHEAD_DEG + 1e-9 for s in arm.sent))   # 先へ出るのは 15° まで
        self.assertLessEqual(len(arm.sent), 2 + gt.HOLD_MAX_RESENDS)   # 1 刻み + 長押しの 1 回 + 送り直し (3 回まで)
        self.assertEqual(arm.halts, 1)                # 止めたときに stop を送る

    def test_short_press_after_hold_starts_from_command(self):
        tp, arm, log, _ = make()                      # FakeArm は目標へすぐ着く
        tp.press(gt.BTN_RIGHT, 0.0); tp.poll()
        tp.tick(0.4); tp.poll()
        tp.release(gt.BTN_RIGHT, 0.45)
        tp.poll()                                     # 離したときの stop が終わる
        last = arm.sent[-1][0]
        press(tp, gt.BTN_RIGHT, 1.0)
        self.assertAlmostEqual(arm.sent[-1][0], last + 0.5)
        self.assertEqual(log.rows[-1]["start_source"], "command")


class TestHalt(unittest.TestCase):
    """止める命令 (2026-11-05): 長押しの終わり・停止で daemon の stop を送る."""

    def test_stop_during_home_sends_halt(self):
        tp, arm, log, spawned = make([5.0, 0, 0, 0, 0, -30], sync=False)
        tp.press(gt.BTN_HOME, 0.0); tp.tick(1.0)
        spawned.pop()(); tp.poll()                 # HOME を送った (FakeArm はすぐ着くが, 実機は動いている途中)
        tp.set_triggers(1.0, 1.0)
        self.assertEqual(len(spawned), 1)          # 停止中でも stop は送る
        spawned.pop()(); tp.poll()
        self.assertEqual(arm.halts, 1)
        self.assertTrue(tp.stopped)

    def test_halt_waits_for_running_job_and_is_not_cancelled(self):
        tp, arm, log, spawned = make(sync=False)
        tp.press(gt.BTN_RIGHT, 0.0)
        tp.tick(0.4)                               # 長押しの 1 回目 (まだ走らせていない)
        job = spawned.pop()
        job(); tp.poll()                           # 送った
        tp.tick(0.5)                               # 読むだけの仕事
        check = spawned.pop()
        tp.release(gt.BTN_RIGHT, 0.55)             # 離した: 走っている仕事があるので stop は待つ
        self.assertEqual(spawned, [])
        check(); tp.poll()
        self.assertEqual(len(spawned), 1)          # 仕事が終わってから stop
        tp.set_triggers(1.0, 1.0)                  # stop を送る前に停止しても, stop は取り消さない
        spawned.pop()(); tp.poll()
        self.assertGreaterEqual(arm.halts, 1)

    def test_halt_pins_other_joints_no_drift(self):
        # stop のあと j1 が 1° 沈む (11/5 の実機). 止めるたびに 読んだ角度を出発点にすると積み上がった
        arm = LagArm(start=[0, 30.0, 0, 0, 0, -30], lag_step=5.0)
        tp, log, out = make_arm(arm)
        orig_halt = arm.halt
        def sag_halt():
            arm.q[1] += 1.0
            arm.target[1] += 1.0
            return orig_halt()
        arm.halt = sag_halt
        t = 0.0
        for _ in range(5):                            # 長押しを 5 回
            tp.press(gt.BTN_RIGHT, t); tp.poll()
            for k in range(4):
                tp.tick(t + 0.4 + 0.1 * k); tp.poll()
            tp.release(gt.BTN_RIGHT, t + 0.8); tp.poll()
            t += 2.0
        self.assertAlmostEqual(tp.cmd[1], 30.0)       # 指令の j1 は動かない
        self.assertTrue(all(abs(s_[1] - 30.0) < 1e-9 for s_ in arm.sent))
        halts = [r for r in log.rows if r["kind"] == "halt"]
        self.assertEqual(len(halts), 5)
        self.assertTrue(all("pin_sent" not in r for r in halts))   # 止めたあとは何も送らない

    def test_daemon_arm_step_ignores_other_joints_sag(self):
        # j1 が重さで 5° 下がっていても, 動かした j0 が着けば「届いた」 (11/5)
        saved = {k: getattr(gt, k) for k in ("daemon_send_angles", "rpc", "wr", "SPEED", "POLL_SLEEP", "REACH_TIMEOUT_SEC",
                                             "daemon_request")}
        try:
            gt.daemon_request = lambda c, **k: {"ok": True}
            gt.rpc = types.SimpleNamespace(SEND_WAIT=0, read_angles=lambda: np.array([1.0, 45.0, 0, 0, 0, -30]))
            gt.wr = types.SimpleNamespace(_compute_arrival_timeout=lambda d: 8.0)
            gt.daemon_send_angles = lambda a, **k: None
            gt.SPEED, gt.POLL_SLEEP, gt.REACH_TIMEOUT_SEC = 20, 0.0, 8.0
            r = gt.DaemonArm().send_and_check(np.array([1.0, 40.0, 0, 0, 0, -30]), gt.SendGate(), threading.Event(), joint=0)
            self.assertTrue(r["reached"])
        finally:
            for k, v in saved.items():
                setattr(gt, k, v)

    def test_daemon_arm_step_settles_short_without_waiting(self):
        # j2 が重さで 1.6° 手前で止まる: 8 秒待たずに「手前で止まった」で戻る (11/5)
        saved = {k: getattr(gt, k) for k in ("daemon_send_angles", "rpc", "wr", "SPEED", "POLL_SLEEP", "REACH_TIMEOUT_SEC",
                                             "daemon_request")}
        try:
            seq = iter([0.0, -0.3, -0.6, -0.6, -0.6, -0.6, -0.6, -0.6, -0.6, -0.6, -0.6, -0.6] + [-0.6] * 200)
            gt.rpc = types.SimpleNamespace(SEND_WAIT=0, read_angles=lambda: np.array([0.0, 0, next(seq), 0, 0, -30]))
            gt.wr = types.SimpleNamespace(_compute_arrival_timeout=lambda d: 8.0)
            gt.daemon_request = lambda c, **k: {"ok": True}
            gt.SPEED, gt.POLL_SLEEP, gt.REACH_TIMEOUT_SEC = 20, 0.3, 8.0
            t0 = time.monotonic()
            r = gt.DaemonArm().send_and_check(np.array([0.0, 0, -2.0, 0, 0, -30]), gt.SendGate(), threading.Event(), joint=2)
            self.assertLess(time.monotonic() - t0, 1.0)
            self.assertTrue(r["settled"])
            self.assertEqual(r["resends"], [])
        finally:
            for k, v in saved.items():
                setattr(gt, k, v)

    def test_hold_resends_when_stopped_short(self):
        # 次の目標が無視されて前の目標の手前で止まった: 0.3 秒たったら同じ目標を送り直す (11/5)
        arm = LagArm(lag_step=0.0)
        tp, log, out = make_arm(arm)
        tp.press(gt.BTN_RIGHT, 0.0); tp.poll()
        tp.tick(0.4); tp.poll()                       # 15° 先を送った (でも動かない)
        tp.hold_sent["t"] -= 1.0                      # 送ってから 1 秒たったことにする
        tp.tick(0.5); tp.poll()
        self.assertEqual(log.rows[-1].get("hold_resend"), True)
        self.assertEqual(arm.sent[-1], arm.sent[-2])  # 同じ目標
        self.assertEqual(tp.hold_sent["resends"], 1)

    def test_daemon_arm_step_not_moved_returns_and_resends(self):
        saved = {k: getattr(gt, k) for k in ("daemon_send_angles", "rpc", "wr", "SPEED", "POLL_SLEEP", "REACH_TIMEOUT_SEC",
                                             "IGNORED_CHECK_SEC", "daemon_request")}
        try:
            sends = []
            gt.rpc = types.SimpleNamespace(SEND_WAIT=0, read_angles=lambda: np.array([0.0, 0, 0, 0, 0, -30]))
            gt.wr = types.SimpleNamespace(_compute_arrival_timeout=lambda d: 8.0)
            gt.daemon_send_angles = lambda a, **k: self.fail("1 刻みで 6 関節を送った")
            gt.daemon_request = lambda c, **k: (sends.append(c) or {"ok": True})
            gt.SPEED, gt.POLL_SLEEP, gt.REACH_TIMEOUT_SEC, gt.IGNORED_CHECK_SEC = 20, 0.0, 8.0, 0.01
            t0 = time.monotonic()
            r = gt.DaemonArm().send_and_check(np.array([1.0, 0, 0, 0, 0, -30]), gt.SendGate(), threading.Event(), joint=0)
            self.assertLess(time.monotonic() - t0, 1.0)  # 8 秒待たない
            self.assertFalse(r["reached"])              # 1° 動かなければ「届いた」にしない (3° の判定では届いたになっていた)
            self.assertTrue(r["not_moved"])
            self.assertEqual(len(sends), 1 + gt.STEP_MAX_RESENDS)
            self.assertEqual({(c["cmd"], c["joint_id"], c["angle"]) for c in sends}, {("send_angle", 1, 1.0)})
        finally:
            for k, v in saved.items():
                setattr(gt, k, v)

    def test_not_moved_step_accumulates(self):
        tp, arm, log, _ = make()
        def nm(target, gate, quitting, check=True, joint=None):
            gate.run(lambda: arm.sent.append([float(v) for v in target]))
            return {"sent": True, "reached": False, "not_moved": True, "reached_angles": gt._r(arm.q)}
        arm.send_and_check = nm
        press(tp, gt.BTN_RIGHT)
        press(tp, gt.BTN_RIGHT)
        self.assertAlmostEqual(arm.sent[-1][0], 1.0)  # 動かなくても 押した分は積み上がる (0.5 → 1.0)

    def test_short_press_does_not_send_halt(self):
        tp, arm, log, _ = make()
        press(tp, gt.BTN_RIGHT)
        tp.tick(0.5); tp.poll()
        self.assertEqual(getattr(arm, "halts", 0), 0)

    def test_halt_error_does_not_stop_or_loop(self):
        tp, arm, log, _ = make()
        arm.halt = lambda: {"halt_error": "boom", "measured_after_halt": None}
        tp.press(gt.BTN_RIGHT, 0.0); tp.poll()
        tp.tick(0.4); tp.poll()
        tp.release(gt.BTN_RIGHT, 0.5); tp.poll()
        self.assertFalse(tp.stopped)
        self.assertFalse(tp.busy)
        self.assertEqual(sum(r["kind"] == "halt" for r in log.rows), 1)

    def test_daemon_arm_halt_falls_back_without_stop_cmd(self):
        calls = []
        saved = {k: getattr(gt, k) for k in ("daemon_request", "daemon_send_angles", "rpc", "SPEED", "HALT_SETTLE_SEC")}
        try:
            gt.daemon_request = lambda c, timeout_override=None: (calls.append(("req", c)) or {"ok": False, "error": "unknown cmd: stop"})
            gt.daemon_send_angles = lambda a, **k: calls.append(("send", a))
            gt.rpc = types.SimpleNamespace(SEND_WAIT=0, read_angles=lambda: np.array([1.0, 2, 3, 4, 5, -30]))
            gt.SPEED = 20
            gt.HALT_SETTLE_SEC = 0.0
            r = gt.DaemonArm().halt()
            self.assertEqual(r["halt"], "send_current")
            self.assertEqual(calls[0], ("req", {"cmd": "stop"}))
            self.assertEqual(calls[1], ("send", [1.0, 2.0, 3.0, 4.0, 5.0, -30.0]))
            gt.daemon_request = lambda c, timeout_override=None: {"ok": True, "result": {"jog_stop": 0, "stop": 0}}
            calls.clear()
            r = gt.DaemonArm().halt()
            self.assertEqual(r["halt"], "stop")
            self.assertEqual(calls, [])
        finally:
            for k, v in saved.items():
                setattr(gt, k, v)

    def test_daemon_arm_check_returns_when_stopped(self):
        # HOME の途中で L2+R2: 届くのを待たずに戻る (戻れば すぐ stop を送れる. レビュー 必須 1)
        saved = {k: getattr(gt, k) for k in ("daemon_send_angles", "rpc", "wr", "SPEED", "POLL_SLEEP", "REACH_TIMEOUT_SEC")}
        try:
            gate = gt.SendGate()
            reads = []
            def read_angles():
                reads.append(1)
                if len(reads) == 3:
                    gate.cancel_now()                  # 動いている途中で停止
                return np.array([20.0, 0, 0, 0, 0, -30]) - len(reads)
            gt.rpc = types.SimpleNamespace(SEND_WAIT=0, read_angles=read_angles)
            gt.wr = types.SimpleNamespace(_compute_arrival_timeout=lambda d: 8.0)
            gt.daemon_send_angles = lambda a, **k: None
            gt.SPEED, gt.POLL_SLEEP, gt.REACH_TIMEOUT_SEC = 20, 0.0, 8.0
            t0 = time.monotonic()
            r = gt.DaemonArm().send_and_check(np.array(HOME), gate, threading.Event())
            self.assertLess(time.monotonic() - t0, 1.0)
            self.assertTrue(r["halted_during_check"])
            self.assertLessEqual(len(reads), 4)
        finally:
            for k, v in saved.items():
                setattr(gt, k, v)

    def test_arm_error_also_sends_halt(self):
        tp, arm, log, _ = make()
        def bad(target, gate, quitting, check=True, joint=None):
            gate.run(lambda: None)
            return {"sent": "unknown", "error": "send_failed: timeout"}
        arm.send_and_check = bad
        press(tp, gt.BTN_RIGHT); tp.poll()
        self.assertTrue(tp.stopped)
        self.assertEqual(arm.halts, 1)
        self.assertEqual(sum(r["kind"] == "halt" for r in log.rows), 1)   # 失敗しても繰り返さない

    def test_exit_during_hold_sends_halt_once(self):
        tp, arm, log, _ = make()
        tp.press(gt.BTN_RIGHT, 0.0); tp.poll()
        tp.tick(0.4); tp.poll()                       # 長押しの目標を送った
        self.assertTrue(tp.shutdown())
        self.assertEqual(arm.halts, 1)
        self.assertEqual([r for r in log.rows if r["kind"] == "halt"][-1]["halt_reason"], "exit")

    def test_exit_when_idle_sends_nothing(self):
        tp, arm, log, _ = make()
        press(tp, gt.BTN_RIGHT)
        self.assertTrue(tp.shutdown())
        self.assertEqual(getattr(arm, "halts", 0), 0)

    def test_dry_arm_hold_moves_on_read_and_halt_stops(self):
        arm = gt.DryArm()
        tp, log, out = make_arm(arm)
        tp.press(gt.BTN_RIGHT, 0.0); tp.poll()
        t = 0.4
        for _ in range(20):
            tp.tick(t); tp.poll()
            t += 0.1
        self.assertGreater(arm.q[0], 15.0)            # 読むたびに寄っていく
        tp.release(gt.BTN_RIGHT, t); tp.poll()
        q = arm.read().copy()
        self.assertTrue(np.allclose(arm.read(), q))


class TestHoldReview(unittest.TestCase):
    """2026-11-03 のレビューの指摘 (A: 受け付けられなかった押し / B: 離したあとの 1 刻み / D: 離した合図の取りこぼし)."""

    def test_press_while_busy_starts_hold_when_free(self):
        # 11/5: 動いている間に押して押し続けていれば, 空いてから長押しになる (向きを変えたとき受付なしにしない)
        tp, arm, log, spawned = make(sync=False)
        press(tp, gt.BTN_CROSS)                       # 爪の仕事で busy
        self.assertTrue(tp.busy)
        tp.press(gt.BTN_DOWN, 0.0)
        self.assertIsNotNone(tp.hold)
        tp.tick(0.4)                                  # まだ空いていない: 送らない
        self.assertEqual(len(spawned), 1)
        spawned.pop(0)()                              # 爪の仕事が終わる
        tp.poll()
        for t in (0.6, 0.8, 1.0):
            tp.tick(t)
            while spawned:
                spawned.pop(0)()
            tp.poll()
        self.assertGreaterEqual(len(arm.sent), 1)
        self.assertAlmostEqual(arm.sent[0][1], gt.HOLD_AHEAD_DEG)

    def test_press_rejected_while_stopped_does_not_become_hold(self):
        tp, arm, log, _ = make()
        tp.set_triggers(0.5, 0.5)
        tp.press(gt.BTN_RIGHT, 0.0)
        self.assertIsNone(tp.hold)

    def test_release_cancels_uncommitted_repeat(self):
        tp, arm, log, spawned = make(sync=False)
        out = []
        tp.out = out.append
        tp.press(gt.BTN_RIGHT, 0.0)
        tp.tick(0.4)                                  # 長押しの 1 回目の仕事ができた (まだ走っていない)
        tp.release(gt.BTN_RIGHT, 0.45)                # 走る前に離した
        spawned.pop(0)(); tp.poll()
        self.assertEqual(len(arm.sent), 0)
        last = [r for r in log.rows if r["kind"] == "move"][-1]
        self.assertEqual(last["why"], "halted_before_send")
        self.assertTrue(last["released_during_job"])
        self.assertFalse(tp.stopped)
        self.assertFalse(any("停止が入った" in o for o in out))
        tp.press(gt.BTN_RIGHT, 1.0)                   # 次の押しは普通に動く
        tp.release(gt.BTN_RIGHT, 1.05)
        spawned.pop(0)(); tp.poll()
        self.assertEqual(len(arm.sent), 1)

    def test_release_does_not_cancel_first_step(self):
        tp, arm, log, spawned = make(sync=False)
        tp.press(gt.BTN_RIGHT, 0.0)
        tp.release(gt.BTN_RIGHT, 0.05)                # 短い押し: 仕事が走る前に離しても 1 刻みは送る
        spawned.pop(0)(); tp.poll()
        self.assertEqual(len(arm.sent), 1)

    def test_loop_releases_hold_when_button_state_is_up(self):
        import pygame
        pygame.init()
        screen = pygame.display.set_mode((200, 100))
        fonts = {k: pygame.font.SysFont("menlo", 12) for k in ("big", "mono", "small")}
        js = FakeJoystick(0)
        js.held = set()
        tp, arm, log, _ = make()
        frames = {
            0: lambda: pygame.event.post(pygame.event.Event(pygame.JOYDEVICEADDED, device_index=0)),
            2: lambda: (js.held.add(gt.BTN_RIGHT),
                        pygame.event.post(pygame.event.Event(pygame.JOYBUTTONDOWN, instance_id=7, button=gt.BTN_RIGHT, joy=0))),
            4: lambda: js.held.discard(gt.BTN_RIGHT),   # 離した合図 (JOYBUTTONUP) は来ない
            60: lambda: pygame.event.post(pygame.event.Event(pygame.QUIT)),
        }
        seen = {}

        def on_frame(n, t):
            frames.get(n, lambda: None)()
            if n == 3:
                seen["hold_at_3"] = t.hold is not None
        with mock.patch.object(gt.pygame.joystick, "Joystick", lambda i: js):
            gt.run_loop(tp, screen, fonts, "TEST", on_frame=on_frame)
        pygame.quit()
        self.assertTrue(seen["hold_at_3"])            # 押している間は長押しのまま
        self.assertIsNone(tp.hold)                    # 状態が離れたら止まる
        self.assertEqual(len(arm.sent), 1)            # 1 秒近くたっても 1 刻みだけ


class TestCodexR1(unittest.TestCase):
    def test_selected_joint_out_of_window_not_clamped(self):
        # 基準 j1=100 (守りの外) から −0.5 を押しても 90.87 へ 1 回で動かさない
        start = [0.0, 100.0, 0.0, 0.0, 0.0, -30.0]
        p = gt.plan_step(start, 1, -0.5)
        self.assertIsNone(p["target"])
        self.assertEqual(p["refused"][0]["why"], "start_out_of_window")
        tp, arm, log, _ = make(start)
        press(tp, gt.BTN_UP)
        self.assertEqual(arm.sent, [])

    def test_physical_limit_ends(self):
        base = [0.0, 0.0, 170.0, 0.0, 0.0, -30.0]       # j2 の物理可動域は ±177
        p = gt.plan_step(base, 2, 20.0)
        self.assertAlmostEqual(p["target"][2], 177.0)
        self.assertEqual(p["clip"][0]["limit"], "PHYS")

    def test_gap_between_command_and_measured(self):
        tp, arm, log, _ = make()
        press(tp, gt.BTN_RIGHT)                         # 目標 j0=0.5 に届いた
        arm.q[0] = 10.0                                 # 手で動かされた
        press(tp, gt.BTN_RIGHT)
        self.assertAlmostEqual(arm.sent[-1][0], 10.5)   # 今読んだ角度から 0.5
        self.assertEqual(log.rows[-1]["start_source"], "measured")
        arm.q[0] = -170.0                               # 物理可動域 (-156) の外 → 送らない
        press(tp, gt.BTN_RIGHT)
        self.assertEqual(len(arm.sent), 2)

    def test_home_hold_across_busy_is_dropped(self):
        tp, arm, log, spawned = make(sync=False)
        press(tp, gt.BTN_RIGHT)                         # 動いている
        tp.press(gt.BTN_HOME, 0.0)                      # busy 中に押し始め
        spawned.pop()()
        tp.poll()                                       # 0.5 秒で空く
        tp.tick(1.5)
        self.assertEqual(spawned, [])
        self.assertIn(("home", False, "busy"), [(r["kind"], r.get("accepted"), r.get("reason")) for r in log.rows])

    def test_home_hold_dropped_when_other_op_starts(self):
        tp, arm, log, spawned = make(sync=False)
        tp.press(gt.BTN_HOME, 0.0)
        press(tp, gt.BTN_RIGHT)                         # 長押しの途中で j0 を動かした
        tp.tick(0.2)
        spawned.pop()()
        tp.poll()
        tp.tick(1.5)
        self.assertEqual(spawned, [])
        self.assertIn("hold_cancelled", [r.get("reason") for r in log.rows])

    def test_home_hold_dropped_by_stop_and_disconnect(self):
        tp, arm, log, _ = make()
        tp.press(gt.BTN_HOME, 0.0)
        tp.set_triggers(1, 1)
        tp.set_triggers(-1, -1)
        press(tp, gt.BTN_OPTIONS)
        tp.tick(1.5)
        self.assertEqual(arm.sent, [])
        tp.r1 = True
        tp.press(gt.BTN_HOME, 2.0)
        tp.controller_lost("controller_disconnected")
        self.assertFalse(tp.r1)
        press(tp, gt.BTN_OPTIONS)
        tp.tick(5.0)
        self.assertEqual(arm.sent, [])

    def test_resume_does_not_revive_old_job(self):
        tp, arm, log, spawned = make(sync=False)
        press(tp, gt.BTN_RIGHT)
        tp.set_triggers(1, 1)
        tp.set_triggers(-1, -1)
        press(tp, gt.BTN_OPTIONS)                       # すぐ再開
        self.assertFalse(tp.stopped)
        spawned.pop()()                                 # 止める前の仕事が走る
        tp.poll()
        self.assertEqual(arm.sent, [])

    def test_home_read_failure_does_not_send(self):
        tp, arm, log, _ = make([5.0, 0, 0, 0, 0, -30])
        reads = iter([None, np.array(HOME)])
        arm.read = lambda: next(reads)
        tp.press(gt.BTN_HOME, 0.0)
        tp.tick(1.0)
        tp.poll()
        self.assertEqual(arm.sent, [])
        self.assertTrue(tp.stopped)

    def test_log_failure_blocks_resume(self):
        tp, arm, log, _ = make()

        def bad(rec):
            raise OSError("disk gone")
        log.write = bad
        press(tp, gt.BTN_TRIANGLE)
        self.assertTrue(tp.stopped)
        press(tp, gt.BTN_OPTIONS)
        self.assertTrue(tp.stopped)
        press(tp, gt.BTN_RIGHT)
        self.assertEqual(arm.sent, [])

    def test_loop_reads_triggers_before_buttons(self):
        import pygame
        pygame.init()
        screen = pygame.display.set_mode((200, 100))
        fonts = {k: pygame.font.SysFont("menlo", 12) for k in ("big", "mono", "small")}
        js = FakeJoystick(0)
        tp, arm, log, _ = make()
        frames = {
            0: lambda: pygame.event.post(pygame.event.Event(pygame.JOYDEVICEADDED, device_index=0)),
            2: lambda: (js.axes.__setitem__(slice(4, 6), [1.0, 1.0]),
                        pygame.event.post(pygame.event.Event(pygame.JOYBUTTONDOWN, instance_id=7, button=gt.BTN_RIGHT, joy=0))),
            4: lambda: pygame.event.post(pygame.event.Event(pygame.QUIT)),
        }
        with mock.patch.object(gt.pygame.joystick, "Joystick", lambda i: js):
            gt.run_loop(tp, screen, fonts, "TEST", on_frame=lambda n, t: frames.get(n, lambda: None)())
        self.assertEqual(arm.sent, [])
        self.assertTrue(tp.stopped)
        # 読み取りに失敗したコマのボタンも処理しない
        js2 = FakeJoystick(0)
        js2.get_axis = lambda a: (_ for _ in ()).throw(pygame.error("gone"))
        tp2, arm2, log2, _ = make()
        frames2 = {
            0: lambda: pygame.event.post(pygame.event.Event(pygame.JOYDEVICEADDED, device_index=0)),
            2: lambda: pygame.event.post(pygame.event.Event(pygame.JOYBUTTONDOWN, instance_id=7, button=gt.BTN_RIGHT, joy=0)),
            4: lambda: pygame.event.post(pygame.event.Event(pygame.QUIT)),
        }
        with mock.patch.object(gt.pygame.joystick, "Joystick", lambda i: js2):
            gt.run_loop(tp2, screen, fonts, "TEST", on_frame=lambda n, t: frames2.get(n, lambda: None)())
        self.assertEqual(arm2.sent, [])
        self.assertTrue(tp2.stopped)
        pygame.quit()


class TestShutdown(unittest.TestCase):
    def test_uncommitted_job_is_not_sent_at_exit(self):
        tp, arm, log, spawned = make(sync=False)
        press(tp, gt.BTN_RIGHT)
        tp.shutdown()                                # 仕事はまだ走っていない
        spawned.pop()()                              # 終了のあとに走っても送らない
        self.assertEqual(arm.sent, [])
        last = [r for r in log.rows if r["kind"] == "move"][0]
        self.assertIs(last["send_committed_before_exit"], False)
        self.assertTrue(last["interrupted_at_exit"])

    def test_committed_send_is_waited_and_recorded(self):
        tp, arm, log, _ = make(sync=False)
        release = threading.Event()
        entered = threading.Event()
        orig = arm.send_and_check

        def slow(target, gate, quitting, check=True, joint=None):
            def fn():
                entered.set()
                release.wait(2.0)
                arm.sent.append([float(v) for v in target])
            if not gate.run(fn):
                return {"sent": False, "why": "halted_before_send"}
            return {"sent": True, "reached": None, "interrupted": True}
        arm.send_and_check = slow
        tp.spawn = tp._spawn_thread
        press(tp, gt.BTN_RIGHT)
        entered.wait(1.0)
        threading.Timer(0.2, release.set).start()
        done = tp.shutdown()                          # 送ると決めた送信が戻るまで待つ
        self.assertTrue(done)
        self.assertEqual(len(arm.sent), 1)
        move = [r for r in log.rows if r["kind"] == "move"][0]
        self.assertIs(move["send_committed_before_exit"], True)

    def test_worker_not_done_returns_false(self):
        tp, arm, log, _ = make(sync=False)
        entered = threading.Event()

        def stuck(target, gate, quitting, check=True, joint=None):
            gate.run(lambda: (entered.set(), threading.Event().wait(1.0)))
            return {"sent": True}
        arm.send_and_check = stuck
        tp.spawn = tp._spawn_thread
        press(tp, gt.BTN_RIGHT)
        entered.wait(1.0)
        with mock.patch.object(gt, "WORKER_JOIN_SEC", 0.1):
            self.assertFalse(tp.shutdown())           # 戻らない → main はソケットを閉じない


class TestStop(unittest.TestCase):
    def test_both_triggers(self):
        tp, arm, log, _ = make()
        tp.set_triggers(0.5, -1.0)
        self.assertFalse(tp.stopped)
        tp.set_triggers(-1.0, 0.9)
        self.assertFalse(tp.stopped)
        tp.set_triggers(0.0, 0.0)                  # ちょうど 0.0 は超えていない
        self.assertFalse(tp.stopped)
        tp.set_triggers(0.01, 0.01)
        self.assertTrue(tp.stopped)
        self.assertEqual(tp.state, gt.STATE_STOPPED)

    def test_stopped_rejects_and_options_resumes(self):
        tp, arm, log, _ = make()
        tp.set_triggers(1.0, 1.0)
        for b in (gt.BTN_RIGHT, gt.BTN_DOWN, gt.BTN_CROSS, gt.BTN_CIRCLE):
            press(tp, b)
        tp.press(gt.BTN_HOME, 0.0)
        tp.tick(2.0)
        tp.release(gt.BTN_HOME, 2.0)
        self.assertEqual(arm.sent, [])
        self.assertEqual(arm.grips, [])
        self.assertTrue(all(r.get("reason") == "stopped" for r in log.rows
                            if r["kind"] in ("move", "grip_close", "grip_open", "home")))
        press(tp, gt.BTN_OPTIONS)                  # まだ L2+R2 を押している → 戻らない
        self.assertTrue(tp.stopped)
        tp.set_triggers(-1.0, -1.0)
        press(tp, gt.BTN_OPTIONS)
        self.assertFalse(tp.stopped)
        press(tp, gt.BTN_RIGHT)
        self.assertEqual(len(arm.sent), 1)

    def test_disconnect_and_read_error(self):
        tp, arm, log, _ = make()
        tp.controller_lost("controller_disconnected")
        self.assertTrue(tp.stopped)
        self.assertEqual(log.rows[-1]["kind"], "controller_lost")
        tp2, arm2, log2, _ = make()
        tp2.controller_read_failed("x")
        tp2.controller_read_failed("x")
        self.assertTrue(tp2.stopped)
        self.assertEqual(sum(r["kind"] == "read_error" for r in log2.rows), 1)
        press(tp2, gt.BTN_OPTIONS)                 # 読めないうちは戻らない
        self.assertTrue(tp2.stopped)
        tp2.controller_read_ok()
        press(tp2, gt.BTN_OPTIONS)
        self.assertFalse(tp2.stopped)

    def test_stop_while_moving_blocks_send(self):
        tp, arm, log, spawned = make(sync=False)
        press(tp, gt.BTN_RIGHT)                    # 仕事を作ったが, まだ走らせていない
        self.assertTrue(tp.busy)
        tp.set_triggers(1.0, 1.0)                  # 動いている間でも停止は受け付ける
        self.assertTrue(tp.stopped)
        spawned.pop()()                            # 仕事が走る: 送る直前に停止を見て送らない
        tp.poll()
        self.assertEqual(arm.sent, [])
        move = [r for r in log.rows if r["kind"] == "move"][0]
        self.assertEqual(move["why"], "halted_before_send")
        self.assertTrue(tp.busy)                   # 停止の stop を送る仕事 (前の仕事が終わってから)
        spawned.pop()()
        tp.poll()
        self.assertFalse(tp.busy)
        self.assertEqual(arm.halts, 1)
        self.assertEqual([r for r in log.rows if r["kind"] == "halt"][0]["halt_reason"], "L2+R2")


class TestHomeHold(unittest.TestCase):
    def test_short_press_does_not_send(self):
        tp, arm, log, _ = make()
        tp.press(gt.BTN_HOME, 10.0)
        tp.tick(10.5)
        tp.tick(10.99)
        tp.release(gt.BTN_HOME, 10.99)
        tp.poll()
        self.assertEqual(arm.sent, [])
        self.assertEqual(log.rows[-1]["kind"], "home_short")

    def test_hold_sends_once(self):
        tp, arm, log, _ = make([5.0, 0, 0, 0, 0, -30])
        tp.press(gt.BTN_HOME, 0.0)
        for t in (0.5, 1.0, 1.5, 3.0, 5.0):
            tp.tick(t)
            tp.poll()
        tp.release(gt.BTN_HOME, 5.0)
        self.assertEqual(arm.sent, [HOME])
        self.assertEqual(sum(r["kind"] == "home" for r in log.rows), 1)
        self.assertFalse(any(r["kind"] == "home_short" for r in log.rows))
        tp.press(gt.BTN_HOME, 6.0)                 # 押し直せば, また 1 回
        tp.tick(7.0)
        tp.poll()
        self.assertEqual(len(arm.sent), 2)


class TestBusy(unittest.TestCase):
    def test_presses_while_moving_are_dropped(self):
        tp, arm, log, spawned = make(sync=False)
        press(tp, gt.BTN_RIGHT)
        for b in (gt.BTN_RIGHT, gt.BTN_DOWN, gt.BTN_CROSS):
            press(tp, b)
        tp.press(gt.BTN_HOME, 0.0)
        tp.tick(1.5)
        self.assertEqual(len(spawned), 1)           # 仕事は 1 つだけ
        spawned.pop()()
        tp.poll()
        self.assertEqual(len(spawned), 1)           # 最後の十字キー (下) だけ 終わってから送る
        spawned.pop()()
        tp.poll()
        tp.tick(3.0)                                # 長押しのまま空いても, ためた HOME は出ない
        self.assertEqual(spawned, [])
        # 動いている間の十字キーは, 最後の 1 つだけ 終わってから送る (11/5). 爪と HOME は受付なし
        self.assertEqual(arm.sent[0], [0.5, 0, 0, 0, 0, -30])
        busy = [r for r in log.rows if r.get("reason") == "busy"]
        self.assertEqual(len(busy), 2)


class TestDaemonArm(unittest.TestCase):
    def _arm(self, reads):
        it = iter(reads)
        last = [None]

        def read():
            try:
                last[0] = next(it)
            except StopIteration:
                pass
            return None if last[0] is None else np.asarray(last[0], dtype=float)
        return read

    def test_halt_blocks_send(self):
        sends = []
        with mock.patch.object(gt.rpc, "read_angles", self._arm([HOME])), \
                mock.patch.object(gt, "daemon_send_angles", lambda *a, **k: sends.append(a)):
            gate, quit_ = gt.SendGate(), threading.Event()
            gate.cancel_now()
            r = gt.DaemonArm().send_and_check(np.array(HOME), gate, quit_)
        self.assertEqual(sends, [])
        self.assertFalse(r["sent"])

    def test_single_send_no_retry(self):
        sends = []
        with mock.patch.object(gt.rpc, "read_angles", self._arm([HOME])), \
                mock.patch.object(gt, "daemon_send_angles", lambda a, **k: sends.append((a, k))), \
                mock.patch.object(gt, "REACH_TIMEOUT_SEC", 0.3), \
                mock.patch.object(gt.wr, "_compute_arrival_timeout", lambda d: 0.1), \
                mock.patch.object(gt, "POLL_SLEEP", 0.05):
            r = gt.DaemonArm().send_and_check(np.array([5.0, 0, 0, 0, 0, -30]),
                                              gt.SendGate(), threading.Event())
        self.assertEqual(len(sends), 1)
        self.assertEqual(sends[0][1]["speed"], 20)
        self.assertFalse(r["reached"])
        self.assertTrue(r["sent"])

    def test_ignored_send_is_resent(self):
        # 送っても動かない (無視された) → 0.5 秒後に同じ目標を送り直す → 動いて届く (2026-11-03)
        sends = []
        state = {"q": np.array(HOME, dtype=float)}

        def send(a, **k):
            sends.append(list(a))
            if len(sends) >= 2:                          # 2 回目で通る
                state["q"] = np.array(a, dtype=float)

        target = [5.0, 0, 0, 0, 0, -30]
        with mock.patch.object(gt.rpc, "read_angles", lambda: state["q"].copy()), \
                mock.patch.object(gt, "daemon_send_angles", send), \
                mock.patch.object(gt, "IGNORED_CHECK_SEC", 0.05), \
                mock.patch.object(gt, "POLL_SLEEP", 0.02):
            r = gt.DaemonArm().send_and_check(np.array(target), gt.SendGate(), threading.Event())
        self.assertEqual(len(sends), 2)
        self.assertEqual(sends[1], target)
        self.assertTrue(r["reached"])
        self.assertEqual(len(r["resends"]), 1)

    def test_resend_limited_and_not_when_moving(self):
        sends = []
        with mock.patch.object(gt.rpc, "read_angles", self._arm([HOME])), \
                mock.patch.object(gt, "daemon_send_angles", lambda a, **k: sends.append(a)), \
                mock.patch.object(gt, "IGNORED_CHECK_SEC", 0.02), \
                mock.patch.object(gt, "REACH_TIMEOUT_SEC", 0.5), \
                mock.patch.object(gt.wr, "_compute_arrival_timeout", lambda d: 0.1), \
                mock.patch.object(gt, "POLL_SLEEP", 0.01):
            r = gt.DaemonArm().send_and_check(np.array([5.0, 0, 0, 0, 0, -30]), gt.SendGate(), threading.Event())
        self.assertEqual(len(sends), 1 + gt.IGNORED_MAX_RESENDS)
        self.assertFalse(r["reached"])
        # 動いている (届いていないが 0.3° 以上動いた) なら送り直さない
        sends.clear()
        moving = [HOME] + [[0.5 * (i + 1), 0, 0, 0, 0, -30] for i in range(6)]
        with mock.patch.object(gt.rpc, "read_angles", self._arm(moving)), \
                mock.patch.object(gt, "daemon_send_angles", lambda a, **k: sends.append(a)), \
                mock.patch.object(gt, "IGNORED_CHECK_SEC", 0.0), \
                mock.patch.object(gt, "REACH_TIMEOUT_SEC", 0.2), \
                mock.patch.object(gt.wr, "_compute_arrival_timeout", lambda d: 0.1), \
                mock.patch.object(gt, "POLL_SLEEP", 0.01):
            gt.DaemonArm().send_and_check(np.array([9.0, 0, 0, 0, 0, -30]), gt.SendGate(), threading.Event())
        self.assertEqual(len(sends), 1)

    def test_no_resend_after_stop(self):
        sends = []
        gate = gt.SendGate()
        reads = {"n": 0}

        def read():
            reads["n"] += 1
            if reads["n"] == 2:
                gate.cancel_now()                         # 確かめる途中で L2+R2
            return np.array(HOME, dtype=float)
        with mock.patch.object(gt.rpc, "read_angles", read), \
                mock.patch.object(gt, "daemon_send_angles", lambda a, **k: sends.append(a)), \
                mock.patch.object(gt, "IGNORED_CHECK_SEC", 0.0), \
                mock.patch.object(gt, "POLL_SLEEP", 0.01):
            r = gt.DaemonArm().send_and_check(np.array([5.0, 0, 0, 0, 0, -30]), gate, threading.Event())
        self.assertEqual(len(sends), 1)
        self.assertTrue(r["halted_during_check"])

    def test_reached(self):
        target = [1.0, 0, 0, 0, 0, -30]
        with mock.patch.object(gt.rpc, "read_angles", self._arm([HOME, [0.5, 0, 0, 0, 0, -30]])), \
                mock.patch.object(gt, "daemon_send_angles", lambda a, **k: None):
            r = gt.DaemonArm().send_and_check(np.array(target), gt.SendGate(), threading.Event())
        self.assertTrue(r["reached"])
        self.assertEqual(r["reach_err"], 0.5)

    def test_reach_uses_unrounded_error(self):
        target = [3.004, 0, 0, 0, 0, -30]
        with mock.patch.object(gt.rpc, "read_angles", self._arm([HOME])), \
                mock.patch.object(gt, "daemon_send_angles", lambda a, **k: None), \
                mock.patch.object(gt, "REACH_TIMEOUT_SEC", 0.2), \
                mock.patch.object(gt.wr, "_compute_arrival_timeout", lambda d: 0.1), \
                mock.patch.object(gt, "POLL_SLEEP", 0.05):
            r = gt.DaemonArm().send_and_check(np.array(target), gt.SendGate(), threading.Event())
        self.assertFalse(r["reached"])

    def test_read_none_during_check_is_error(self):
        with mock.patch.object(gt.rpc, "read_angles", self._arm([HOME, None])), \
                mock.patch.object(gt, "daemon_send_angles", lambda a, **k: None):
            r = gt.DaemonArm().send_and_check(np.array([5.0, 0, 0, 0, 0, -30]), gt.SendGate(), threading.Event())
        self.assertTrue(r["sent"])
        self.assertEqual(r["error"], "read_failed_during_check")
        self.assertIsNone(r["reached"])

    def test_stop_does_not_wait_and_reports_commit(self):
        # 送ると決めて送信 (通信) の最中に停止が来ても, 停止は待たない. 決めていたことを返す.
        # 停止のあとに「送ると決める」ことはない
        gate = gt.SendGate()
        entered, release = threading.Event(), threading.Event()

        def slow_send():
            entered.set()
            release.wait(2.0)
        t = threading.Thread(target=lambda: gate.run(slow_send))
        t.start()
        entered.wait(1.0)
        got = []
        stopper = threading.Thread(target=lambda: got.append(gate.cancel_now()))
        stopper.start()
        stopper.join(0.5)
        self.assertFalse(stopper.is_alive())         # 待たされない
        self.assertEqual(got, [True])                # 停止より前に送ると決めていた
        release.set()
        t.join(2.0)
        self.assertFalse(gate.run(lambda: self.fail("取り消しのあとに送った")))
        g2 = gt.SendGate()
        self.assertFalse(g2.cancel_now())            # まだ決めていない仕事は送らない
        self.assertFalse(g2.run(lambda: self.fail("取り消しのあとに送った")))

    def test_send_uses_rpc_timeout(self):
        seen = []
        with mock.patch.object(gt.rpc, "read_angles", self._arm([HOME])), \
                mock.patch.object(gt, "daemon_send_angles", lambda a, **k: seen.append(k)):
            gt.DaemonArm().send_and_check(np.array(HOME), gt.SendGate(), threading.Event())
        self.assertEqual(seen[0]["timeout_override"], gt.SEND_RPC_TIMEOUT_SEC)

    def test_stop_log_says_whether_send_was_committed(self):
        tp, arm, log, spawned = make(sync=False)
        press(tp, gt.BTN_RIGHT)
        tp.set_triggers(1, 1)                       # まだ送ると決めていない
        stop = [r for r in log.rows if r["kind"] == "stop"][0]
        self.assertIs(stop["send_committed_before_stop"], False)

    def test_gripper_gate(self):
        calls = []
        with mock.patch.object(gt, "daemon_set_gripper_value", lambda v: calls.append(("close", v))), \
                mock.patch.object(gt, "daemon_set_gripper", lambda s, wait: calls.append(("open", s, wait))), \
                mock.patch.object(gt.rpc, "GRIPPER_SLEEP", 0.0):
            arm = gt.DaemonArm()
            self.assertTrue(arm.gripper(1, gt.SendGate()))
            self.assertTrue(arm.gripper(0, gt.SendGate()))
            g = gt.SendGate()
            g.cancel_now()
            self.assertFalse(arm.gripper(1, g))
        self.assertEqual(calls, [("close", 20), ("open", 0, gt.rpc.GRIP_OPEN_DAEMON_WAIT)])


class FakeJoystick:
    def __init__(self, idx):
        self.axes = [0.0, 0.0, 0.0, 0.0, -1.0, -1.0]

    def get_instance_id(self):
        return 7

    def get_name(self):
        return "PS4 Controller"

    def get_guid(self):
        return "fake-guid"

    def get_numaxes(self):
        return 6

    def get_numbuttons(self):
        return 16

    def get_numhats(self):
        return 0

    def get_axis(self, a):
        return self.axes[a]

    def get_button(self, b):
        return 1 if b in getattr(self, "held", set()) else 0


class TestDryRunMain(unittest.TestCase):
    def test_flow(self):
        import pygame
        js_holder = {}

        def make_js(idx):
            js_holder["js"] = FakeJoystick(idx)
            return js_holder["js"]

        def btn(b, down=True):
            t = pygame.JOYBUTTONDOWN if down else pygame.JOYBUTTONUP
            pygame.event.post(pygame.event.Event(t, instance_id=7, button=b, joy=0))

        # フレームごとの台本 (ドライランは 1 回 0.3 秒動いている扱い = 60fps で 18 コマ程度)
        script = {
            1: lambda: pygame.event.post(pygame.event.Event(pygame.JOYDEVICEADDED, device_index=0)),
            3: lambda: (btn(gt.BTN_RIGHT), btn(gt.BTN_RIGHT, False)),
            5: lambda: (btn(gt.BTN_RIGHT), btn(gt.BTN_RIGHT, False)),       # 動いている間 → 終わってから送る (11/5)
            40: lambda: btn(gt.BTN_TRIANGLE),
            42: lambda: (btn(gt.BTN_DOWN), btn(gt.BTN_DOWN, False)),
            80: lambda: btn(gt.BTN_CROSS),
            82: lambda: js_holder["js"].axes.__setitem__(slice(4, 6), [0.5, 0.5]),   # 停止
            85: lambda: (btn(gt.BTN_LEFT), btn(gt.BTN_LEFT, False)),
            86: lambda: js_holder["js"].axes.__setitem__(slice(4, 6), [-1.0, -1.0]),
            88: lambda: btn(gt.BTN_OPTIONS),
            150: lambda: btn(gt.BTN_HOME),
            230: lambda: btn(gt.BTN_HOME, False),
            300: lambda: pygame.event.post(pygame.event.Event(pygame.QUIT)),
        }

        def on_frame(n, tp):
            if n in script:
                script[n]()

        logs = []

        class SpyNullLog(gt.NullLog):         # 書かないことは変えずに, 渡された行だけ見る
            def __init__(self):
                self.rows = []
                logs.append(self)

            def write(self, rec):
                json.dumps(rec, ensure_ascii=False)
                self.rows.append(rec)

        out = []
        with tempfile.TemporaryDirectory() as td, \
                mock.patch.object(gt, "LOG_ROOT", Path(td)), \
                mock.patch.object(gt, "ensure_ssd_mounted", side_effect=AssertionError("KIOXIA を確かめた")), \
                mock.patch.object(gt, "load_daemon_modules", side_effect=AssertionError("daemon の送り口を読んだ")), \
                mock.patch.object(gt, "JsonlLog", side_effect=AssertionError("log.jsonl を開いた")), \
                mock.patch.object(gt, "write_json_atomic", side_effect=AssertionError("meta.json を書いた")), \
                mock.patch.object(gt.shutil, "copy2", side_effect=AssertionError("スクリプトを写した")), \
                mock.patch.object(gt, "NullLog", SpyNullLog), \
                mock.patch.object(gt.pygame.joystick, "Joystick", make_js), \
                mock.patch.object(gt, "GRIPPER_SLEEP", 0.2), \
                mock.patch.object(gt, "get_daemon_socket", side_effect=AssertionError("daemon に触った")), \
                mock.patch.object(gt, "daemon_send_angles", side_effect=AssertionError("daemon に送った")), \
                mock.patch("builtins.print", lambda *a, **k: out.append(" ".join(str(x) for x in a))):
            gt.main([], on_frame=on_frame)
            self.assertEqual(list(Path(td).iterdir()), [])        # フォルダも作らない
        self.assertEqual(len(logs), 1)
        rows = logs[0].rows
        self.assertIn("[記録] ドライランなので記録しません", out)
        self.assertIn("[終了] アームは動かしていません。記録なし（ドライラン）", out)
        kinds = [(r["kind"], r.get("accepted"), r.get("reason")) for r in rows]
        # 受け付けた 1 回目の行は届いてから書くので, 動いている間に断った行 (busy) が先に来る
        self.assertIn(("move", True), [k[:2] for k in kinds])
        self.assertIn(("select_joint", True, None), kinds)
        self.assertIn(("stop", True, "L2+R2"), kinds)
        self.assertIn(("move", False, "stopped"), kinds)
        self.assertIn(("resume", True, None), kinds)
        home = [r for r in rows if r["kind"] == "home"]
        self.assertEqual(len(home), 1)
        self.assertEqual(home[0]["command"], HOME)
        moves = [r for r in rows if r["kind"] == "move" and r.get("sent") is True]
        self.assertEqual(moves[0]["command"], [0.5, 0, 0, 0, 0, -30])
        self.assertEqual(moves[1]["command"], [1.0, 0, 0, 0, 0, -30])        # 動いている間の押しは 終わってから (11/5)
        self.assertEqual(moves[2]["command"], [1.0, 0.0, 0.5, 0, 0, -30])    # △ で j2 にしてから下
        for r in moves:
            for key in ("t", "button", "kind", "selected_joint", "step_deg", "measured_before",
                        "command", "reached_angles", "reached", "grip_state", "clip", "stopped"):
                self.assertIn(key, r)
        self.assertTrue(all(r["execute"] is False for r in rows))


class TestExecuteMainRecords(unittest.TestCase):
    """--execute は今までどおり KIOXIA (ここでは一時フォルダ) に log.jsonl・meta.json・写しを書く."""

    def test_execute_writes(self):
        import pygame
        script = {
            1: lambda: pygame.event.post(pygame.event.Event(pygame.JOYDEVICEADDED, device_index=0)),
            3: lambda: (pygame.event.post(pygame.event.Event(pygame.JOYBUTTONDOWN, instance_id=7,
                                                             button=gt.BTN_RIGHT, joy=0)),
                        pygame.event.post(pygame.event.Event(pygame.JOYBUTTONUP, instance_id=7,
                                                             button=gt.BTN_RIGHT, joy=0))),
            60: lambda: pygame.event.post(pygame.event.Event(pygame.QUIT)),
        }
        sends = []
        q = {"now": list(HOME)}

        def send(a, **k):
            sends.append(list(a))
            q["now"] = list(a)

        with tempfile.TemporaryDirectory() as td, \
                mock.patch.object(gt, "LOG_ROOT", Path(td)), \
                mock.patch.object(gt, "ensure_ssd_mounted", lambda: None), \
                mock.patch.object(gt.pygame.joystick, "Joystick", FakeJoystick), \
                mock.patch.object(gt, "get_daemon_socket", lambda: None), \
                mock.patch.object(gt, "close_daemon_socket", lambda: None), \
                mock.patch.object(gt.rpc, "install_daemon_lock", lambda: None), \
                mock.patch.object(gt.rpc, "read_angles", lambda: np.array(q["now"], dtype=float)), \
                mock.patch.object(gt, "daemon_send_angles", send), \
                mock.patch.object(gt, "daemon_request", lambda c, **k: (sends.append(("send_angle", c["joint_id"], c["angle"]))
                                                                        or {"ok": True})), \
                mock.patch("builtins.print", lambda *a, **k: None):
            gt.main(["--execute"], on_frame=lambda n, tp: script[n]() if n in script else None)
            runs = list(Path(td).iterdir())
            self.assertEqual(len(runs), 1)
            run = runs[0]
            self.assertFalse(run.name.endswith("_dry"))
            rows = [json.loads(x) for x in (run / "log.jsonl").read_text().splitlines()]
            meta = json.loads((run / "meta.json").read_text())
            self.assertTrue((run / "gamepad_teleop.py").exists())
        self.assertEqual(sends, [("send_angle", 1, 0.5)])   # 十字キーは動かす関節だけを送る (11/5)
        self.assertEqual([(r["kind"], r.get("sent")) for r in rows], [("move", True)])
        self.assertTrue(rows[0]["execute"])
        self.assertEqual(meta["controllers"][0]["name"], "PS4 Controller")
        self.assertEqual(meta["controllers"][0]["guid"], "fake-guid")
        self.assertTrue(meta["execute"])
        self.assertEqual(meta["end_reason"], "window_closed")
        self.assertEqual(meta["n_sent"], 1)
        self.assertEqual(meta["guards"]["J1_MAX_DEG"], 90.87)
        self.assertEqual(meta["motion"]["speed"], 20)
        for key in ("python", "pygame", "sdl", "start_pose", "git"):
            self.assertIn(key, meta)


class TestNoCv2InDryRun(unittest.TestCase):
    """依頼 69: ドライランは cv2 (cv2/.dylibs/libSDL2) を読まず, objc の重複の警告が出ない.
    このプロセスはもう load_daemon_modules() を呼んでいるので, 別のプロセスで main([]) を回す."""

    CHILD = r'''
import os, sys, tempfile
os.environ["SDL_VIDEODRIVER"] = "dummy"; os.environ["SDL_AUDIODRIVER"] = "dummy"
sys.path.insert(0, sys.argv[1])
from pathlib import Path
import gamepad_teleop as gt
import pygame
td = tempfile.mkdtemp()
gt.LOG_ROOT = Path(td)
gt.SSD_MOUNT = "/nonexistent_kioxia_for_test"     # KIOXIA がなくても動く
gt.main([], on_frame=lambda n, tp: pygame.event.post(pygame.event.Event(pygame.QUIT)) if n == 5 else None)
assert os.listdir(td) == [], os.listdir(td)
bad = [m for m in ("cv2", "run_policy_client", "visual_servo_pick", "j0_sweep_scan") if m in sys.modules]
assert not bad, bad
assert gt.rpc is None and gt.daemon_send_angles is None
print("CHILD_OK")
'''

    def test_dry_run_in_fresh_process(self):
        r = subprocess.run([sys.executable, "-c", self.CHILD, str(Path(gt.__file__).parent)],
                           capture_output=True, text=True, timeout=60)
        self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
        self.assertIn("CHILD_OK", r.stdout)
        self.assertNotIn("is implemented in both", r.stderr)


class TestConstantsMatchModules(unittest.TestCase):
    """読み込まずに読んだ値が, 読み込んだモジュールの値と同じ."""

    def test_same(self):
        self.assertEqual(gt.HOME, [float(v) for v in gt.rpc.HOME])
        self.assertEqual(gt.J4_SAFE, tuple(float(v) for v in gt.rpc.J4_SAFE))
        self.assertEqual(gt.GRIPPER_SLEEP, gt.rpc.GRIPPER_SLEEP)
        self.assertEqual(gt.JOINT_LIMITS, [tuple(float(v) for v in x) for x in gt.wr.JOINT_LIMITS])
        self.assertEqual((gt.SPEED, gt.POLL_SLEEP, gt.REACH_TIMEOUT_SEC), (20, 0.3, 8.0))

    def test_read_constants_rejects_double_assignment(self):
        with tempfile.TemporaryDirectory() as td:
            f = Path(td) / "m.py"
            f.write_text("A = 1\nA = 2\n")
            with self.assertRaises(RuntimeError):
                gt.read_constants(f, ("A",))
            with self.assertRaises(RuntimeError):
                gt.read_constants(f, ("B",))
            for src in ("A = 1\nA += 1\n", "A = 1\nA: int = 2\n", "A = 1\nB = A = 2\n",
                        "A = 1\nfor A in []:\n    pass\n", "A = 1\ndef f():\n    global A\n    A = 2\n"):
                f.write_text(src)
                with self.assertRaises(RuntimeError, msg=src):
                    gt.read_constants(f, ("A",))
            f.write_text("A = 1\ndef f():\n    return A\n")
            self.assertEqual(gt.read_constants(f, ("A",)), {"A": 1})


class TestExecuteLoadGuards(unittest.TestCase):
    def test_no_load_when_ssd_missing(self):
        with mock.patch.object(gt, "ensure_ssd_mounted", side_effect=SystemExit("no ssd")), \
                mock.patch.object(gt, "load_daemon_modules") as load:
            with self.assertRaises(SystemExit) as cm:
                gt.main(["--execute"])
        self.assertEqual(str(cm.exception), "no ssd")
        load.assert_not_called()

    def test_load_failure_aborts_before_record(self):
        with tempfile.TemporaryDirectory() as td, \
                mock.patch.object(gt, "LOG_ROOT", Path(td)), \
                mock.patch.object(gt, "ensure_ssd_mounted", lambda: None), \
                mock.patch.object(gt, "load_daemon_modules", side_effect=ImportError("x")), \
                mock.patch.object(gt, "get_daemon_socket", side_effect=AssertionError("daemon に触った")):
            with self.assertRaises(SystemExit) as cm:
                gt.main(["--execute"])
            self.assertIn("[ABORT]", str(cm.exception))
            self.assertEqual(list(Path(td).iterdir()), [])

    def test_mismatch_aborts_in_fresh_process(self):
        """ファイルから読んだ値と読み込んだ値が違えば, daemon の送り口を入れずに止める (別のプロセス)."""
        child = r'''
import os, sys, tempfile
os.environ["SDL_VIDEODRIVER"] = "dummy"
sys.path.insert(0, sys.argv[1])
from pathlib import Path
import gamepad_teleop as gt
gt.HOME = [1.0, 0.0, 0.0, 0.0, 0.0, -30.0]
td = tempfile.mkdtemp()
gt.LOG_ROOT = Path(td)
gt.ensure_ssd_mounted = lambda: None
try:
    gt.main(["--execute"])
except SystemExit as e:
    assert "HOME" in str(e), e
else:
    raise AssertionError("止まらなかった")
assert os.listdir(td) == [], os.listdir(td)
assert gt.rpc is None and gt.daemon_send_angles is None and gt.get_daemon_socket is None
print("CHILD_OK")
'''
        r = subprocess.run([sys.executable, "-c", child, str(Path(gt.__file__).parent)],
                           capture_output=True, text=True, timeout=60)
        self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
        self.assertIn("CHILD_OK", r.stdout)


if __name__ == "__main__":
    unittest.main()
