| import json |
| import math |
| import threading |
|
|
| import numpy as np |
| import pytest |
|
|
| from vlm.webconsole.demo1.navloop import ( |
| parse_vla_json, build_nav_prompt, VlaError, NavLoopAgent, |
| MAX_STEP_METERS, parse_navila_action, |
| ) |
|
|
|
|
| |
| def test_navila_move_forward_cm(): |
| s = parse_navila_action("The next action is move forward 25 cm.") |
| step = parse_vla_json(s) |
| assert step.action == "move_forward" |
| assert math.isclose(step.to_motion_cmd().value, 0.25) |
|
|
|
|
| def test_navila_turn_left_degrees(): |
| step = parse_vla_json(parse_navila_action("turn left 15 degrees")) |
| assert step.action == "turn_left" |
| assert math.isclose(step.to_motion_cmd().value, math.radians(15)) |
|
|
|
|
| def test_navila_turn_right_degrees(): |
| step = parse_vla_json(parse_navila_action("The next action is turn right 30 degrees.")) |
| assert step.action == "turn_right" and step.to_motion_cmd().value < 0 |
|
|
|
|
| def test_navila_stop_is_finished(): |
| step = parse_vla_json(parse_navila_action("stop, the task is completed.")) |
| assert step.finished and step.to_motion_cmd() is None |
|
|
|
|
| def test_navila_unparsed_falls_back_to_stop(): |
| |
| step = parse_vla_json(parse_navila_action("hmm not sure what to do")) |
| assert step.finished |
|
|
|
|
| def test_navila_loose_forward_without_number(): |
| |
| step = parse_vla_json(parse_navila_action("The next action is move forward a certain distance.")) |
| assert step.action == "move_forward" and not step.finished |
|
|
|
|
| def test_navila_loose_ambiguous_left_right_stops(): |
| |
| step = parse_vla_json(parse_navila_action("turn left or right by a specific degree")) |
| assert step.finished |
|
|
|
|
| |
| def test_parse_move_forward_cm(): |
| s = parse_vla_json('{"action":"move_forward","value":75,"unit":"cm",' |
| '"reasoning":"đi tới","is_finished":false}') |
| assert s.action == "move_forward" and not s.finished |
| mc = s.to_motion_cmd() |
| assert mc.kind == "move" and math.isclose(mc.value, 0.75) |
|
|
|
|
| def test_parse_move_backward_negative(): |
| s = parse_vla_json('{"action":"move_backward","value":0.3,"unit":"m"}') |
| assert math.isclose(s.to_motion_cmd().value, -0.3) |
|
|
|
|
| def test_parse_turn_left_degrees_positive(): |
| s = parse_vla_json('{"action":"turn_left","value":30,"unit":"degrees"}') |
| assert math.isclose(s.to_motion_cmd().value, math.radians(30)) |
|
|
|
|
| def test_parse_turn_right_negative(): |
| s = parse_vla_json('{"action":"turn_right","value":45,"unit":"deg"}') |
| assert math.isclose(s.to_motion_cmd().value, -math.radians(45)) |
|
|
|
|
| def test_parse_strips_markdown_fence(): |
| s = parse_vla_json('```json\n{"action":"stop","is_finished":true,' |
| '"reasoning":"tới rồi"}\n```') |
| assert s.finished and s.to_motion_cmd() is None |
|
|
|
|
| def test_parse_is_finished_without_action_is_stop(): |
| s = parse_vla_json('{"is_finished":true,"reasoning":"done"}') |
| assert s.finished |
|
|
|
|
| def test_parse_obstacles_list(): |
| s = parse_vla_json('{"action":"stop","obstacles_detected":["chair","table"]}') |
| assert s.obstacles == ["chair", "table"] |
|
|
|
|
| def test_parse_clamps_huge_distance(): |
| s = parse_vla_json('{"action":"move_forward","value":999,"unit":"m"}') |
| assert abs(s.to_motion_cmd().value) <= MAX_STEP_METERS |
|
|
|
|
| def test_parse_no_json_raises(): |
| with pytest.raises(VlaError): |
| parse_vla_json("xin chào, không có json ở đây") |
|
|
|
|
| def test_parse_invalid_action_raises(): |
| with pytest.raises(VlaError): |
| parse_vla_json('{"action":"teleport","value":5,"unit":"m"}') |
|
|
|
|
| def test_parse_bad_value_raises(): |
| with pytest.raises(VlaError): |
| parse_vla_json('{"action":"move_forward","value":"abc","unit":"m"}') |
|
|
|
|
| |
| def test_prompt_has_schema_and_goal(): |
| p = build_nav_prompt("đi tới ghế", history=["move forward 0.50 m"], |
| obstacle_hint="chair") |
| assert "đi tới ghế" in p |
| assert "move_forward" in p and "is_finished" in p |
| assert "move forward 0.50 m" in p and "chair" in p |
|
|
|
|
| |
| class FakeBrain: |
| """Trả lần lượt các câu JSON đã định sẵn.""" |
| def __init__(self, outputs): |
| self.outputs = list(outputs) |
| self.calls = 0 |
|
|
| def decide(self, frame, prompt): |
| out = self.outputs[self.calls] |
| self.calls += 1 |
| return out |
|
|
|
|
| class FakeMotion: |
| def __init__(self, error_on=None): |
| self.error_on = error_on |
| self.ran = [] |
|
|
| def run(self, cmd, cancel=None): |
| self.ran.append(cmd) |
| yield {"type": "step", "id": "motion", "status": "running", "title": "go"} |
| if self.error_on: |
| yield {"type": "step", "id": "motion", "status": "error"} |
| yield {"type": "error", "message": self.error_on} |
| return |
| yield {"type": "step", "id": "motion", "status": "done"} |
| yield {"type": "answer", "text": "Done.", "state": "UNKNOWN"} |
|
|
|
|
| class FakeFrames: |
| def get_latest_frame(self): |
| return object() |
|
|
|
|
| def _answers(events): |
| return [e for e in events if e.get("type") == "answer"] |
|
|
|
|
| def test_loop_moves_then_finishes(tmp_path): |
| brain = FakeBrain([ |
| '{"action":"move_forward","value":50,"unit":"cm","reasoning":"tiến"}', |
| '{"action":"turn_left","value":30,"unit":"degrees","reasoning":"xoay"}', |
| '{"action":"stop","is_finished":true,"reasoning":"đã tới ghế"}', |
| ]) |
| motion = FakeMotion() |
| agent = NavLoopAgent(brain, FakeFrames(), motion, log_dir=str(tmp_path), settle_s=0) |
| events = list(agent.run("đi tới ghế")) |
| ans = _answers(events) |
| assert ans and ans[-1]["state"] == "YES" |
| assert ans[-1]["text"] == "đã tới ghế" |
| assert len(motion.ran) == 2 |
| |
| assert len(ans) == 1 |
|
|
|
|
| def test_loop_writes_jsonl_log(tmp_path): |
| brain = FakeBrain([ |
| '{"action":"move_forward","value":50,"unit":"cm","reasoning":"tiến"}', |
| '{"action":"stop","is_finished":true,"reasoning":"xong"}', |
| ]) |
| agent = NavLoopAgent(brain, FakeFrames(), FakeMotion(), log_dir=str(tmp_path), settle_s=0) |
| list(agent.run("đi")) |
| logs = list(tmp_path.glob("nav_*.jsonl")) |
| assert len(logs) == 1 |
| lines = [json.loads(l) for l in logs[0].read_text(encoding="utf-8").splitlines()] |
| assert lines[0]["event"] == "start" |
| assert any(r.get("action") == "move_forward" for r in lines[1:]) |
|
|
|
|
| def test_loop_obstacle_reobserves_not_terminate(tmp_path): |
| |
| brain = FakeBrain([ |
| '{"action":"move_forward","value":100,"unit":"cm","reasoning":"thử đi"}', |
| '{"action":"stop","is_finished":true,"reasoning":"né xong, dừng"}', |
| ]) |
| motion = FakeMotion(error_on="⛔ Vật cản phía trước — đã dừng an toàn.") |
| agent = NavLoopAgent(brain, FakeFrames(), motion, log_dir=str(tmp_path), settle_s=0) |
| events = list(agent.run("đi tới")) |
| ans = _answers(events) |
| assert ans[-1]["state"] == "YES" |
| assert brain.calls == 2 |
|
|
|
|
| def test_loop_terminates_on_motion_timeout(tmp_path): |
| brain = FakeBrain([ |
| '{"action":"move_forward","value":50,"unit":"cm","reasoning":"đi"}', |
| '{"action":"stop","is_finished":true,"reasoning":"không nên tới đây"}', |
| ]) |
| motion = FakeMotion(error_on="Quá thời gian chuyển động.") |
| agent = NavLoopAgent(brain, FakeFrames(), motion, log_dir=str(tmp_path), settle_s=0) |
| events = list(agent.run("đi")) |
| assert any(e.get("type") == "error" for e in events) |
| assert brain.calls == 1 |
|
|
|
|
| def test_loop_respects_cancel(tmp_path): |
| brain = FakeBrain(['{"action":"move_forward","value":50,"unit":"cm"}'] * 5) |
| cancel = threading.Event() |
| cancel.set() |
| agent = NavLoopAgent(brain, FakeFrames(), FakeMotion(), log_dir=str(tmp_path), settle_s=0) |
| events = list(agent.run("đi", cancel=cancel)) |
| assert any("dừng theo yêu cầu" in e.get("message", "") for e in events) |
| assert brain.calls == 0 |
|
|
|
|
| def test_loop_max_steps_cap(tmp_path): |
| |
| brain = FakeBrain(['{"action":"move_forward","value":50,"unit":"cm"}'] * 10) |
| agent = NavLoopAgent(brain, FakeFrames(), FakeMotion(), max_steps=3, |
| log_dir=str(tmp_path), settle_s=0) |
| events = list(agent.run("đi")) |
| assert brain.calls == 3 |
| assert _answers(events)[-1]["state"] == "UNKNOWN" |
|
|
|
|
| class _AnnHint: |
| raw_box = (1, 1, 2, 2) |
| label = "bottle" |
|
|
|
|
| def test_obstacle_hint_from_annotator(tmp_path): |
| |
| agent = NavLoopAgent(FakeBrain([]), FakeFrames(), FakeMotion(), |
| annotator=_AnnHint(), log_dir=str(tmp_path), settle_s=0) |
| assert agent._obstacle_hint(None) == "bottle" |
| |
| agent2 = NavLoopAgent(FakeBrain([]), FakeFrames(), FakeMotion(), |
| log_dir=str(tmp_path), settle_s=0) |
| assert agent2._obstacle_hint(None) is None |
|
|
|
|
| def test_loop_bad_json_terminates(tmp_path): |
| brain = FakeBrain(["không phải json"]) |
| agent = NavLoopAgent(brain, FakeFrames(), FakeMotion(), log_dir=str(tmp_path), settle_s=0) |
| events = list(agent.run("đi")) |
| assert any("JSON VLM hỏng" in e.get("message", "") for e in events) |
|
|
|
|
| |
| class FakeYolo: |
| """detect(wanted) trả box 'bottle' với y2 lấy lần lượt từ y2_list (mô phỏng |
| robot tiến lại gần -> đáy hộp tụt dần về mép dưới ảnh). Call obstacle-hint |
| (wanted=None) trả box cố định, KHÔNG tiêu thụ y2_list.""" |
| names = ["bottle", "chair"] |
|
|
| def __init__(self, y2_list): |
| self.y2 = list(y2_list) |
| self.n = 0 |
|
|
| def detect(self, frame, wanted=None): |
| if not wanted: |
| return [(100, 100, 200, 250, "bottle", 0.9)] |
| y2 = self.y2[min(self.n, len(self.y2) - 1)] |
| self.n += 1 |
| return [(100, 100, 200, y2, "bottle", 0.9)] |
|
|
|
|
| class ShapedFrames: |
| def get_latest_frame(self): |
| return np.zeros((720, 1280, 3), dtype=np.uint8) |
|
|
|
|
| def test_no_override_when_target_at_bottom(tmp_path): |
| brain = FakeBrain(['{"action":"stop","is_finished":true,"reasoning":"tới"}']) |
| motion = FakeMotion() |
| yolo = FakeYolo([460]) |
| agent = NavLoopAgent(brain, ShapedFrames(), motion, detector=yolo, |
| log_dir=str(tmp_path), settle_s=0, stop_bottom_px=50) |
| events = list(agent.run("go to the bottle")) |
| assert not any("YOLO override" in e.get("text", "") for e in events if e.get("type") == "token") |
| assert len(motion.ran) == 0 |
| assert _answers(events)[-1]["state"] == "YES" |
|
|
|
|
| |
| def _servo_agent(tmp_path): |
| return NavLoopAgent(FakeBrain([]), ShapedFrames(), FakeMotion(), |
| log_dir=str(tmp_path), settle_s=0, stop_bottom_px=20) |
|
|
|
|
| class SeqAnn: |
| """annotator giả: trả box lần lượt theo list (mô phỏng robot tiến lại gần).""" |
| label = "bottle" |
|
|
| def __init__(self, boxes): |
| self.boxes = boxes |
| self.i = 0 |
|
|
| def target_box(self): |
| b = self.boxes[min(self.i, len(self.boxes) - 1)] |
| self.i += 1 |
| return b |
|
|
| def frame_height(self): |
| return 720 |
|
|
| def frame_width(self): |
| return 1280 |
|
|
|
|
| class ServoMotion: |
| """Ghi lại các (vx, wz) được bơm + số lần stop (cho visual servo liên tục).""" |
| def __init__(self): |
| self.pubs = [] |
| self.stops = 0 |
|
|
| def _publish(self, vx, wz): |
| self.pubs.append((round(vx, 3), round(wz, 3))) |
|
|
| def _stop(self): |
| self.stops += 1 |
|
|
| def front_distance(self): |
| return None |
|
|
|
|
| def test_visual_servo_turn_then_forward_then_stop(tmp_path): |
| |
| ann = SeqAnn([(1120, 100, 1220, 300), |
| (1120, 100, 1220, 300), |
| (600, 100, 680, 300), |
| (600, 100, 680, 710)]) |
| motion = ServoMotion() |
| ag = NavLoopAgent(FakeBrain([]), ShapedFrames(), motion, annotator=ann, |
| log_dir=str(tmp_path), settle_s=0, stop_bottom_px=20, control="servo") |
| ag.servo_hz = 1000.0; ag.servo_pulse_s = 0; ag.servo_refresh_s = 0; ag.stop_settle_s = 0; ag.final_push_m = 0 |
| events = list(ag.run("go to the bottle")) |
| assert _answers(events)[-1]["state"] == "YES" |
| assert any(vx == 0.0 and wz != 0.0 for vx, wz in motion.pubs) |
| assert any(vx > 0.0 and wz == 0.0 for vx, wz in motion.pubs) |
| assert motion.stops >= 1 |
|
|
|
|
| def test_visual_servo_stops_immediately_at_bottom(tmp_path): |
| |
| ann = SeqAnn([(600, 100, 680, 712), (600, 100, 680, 712)]) |
| motion = ServoMotion() |
| ag = NavLoopAgent(FakeBrain([]), ShapedFrames(), motion, annotator=ann, |
| log_dir=str(tmp_path), settle_s=0, stop_bottom_px=20, control="servo") |
| ag.servo_hz = 1000.0; ag.servo_pulse_s = 0; ag.servo_refresh_s = 0; ag.stop_settle_s = 0; ag.final_push_m = 0 |
| events = list(ag.run("go to the bottle")) |
| assert _answers(events)[-1]["state"] == "YES" |
| assert not any(vx > 0.0 for vx, wz in motion.pubs) |
| assert motion.stops >= 1 |
|
|
|
|
| def test_state_text_injects_geometry(tmp_path): |
| ag = _servo_agent(tmp_path) |
| s = ag._state_text((1000, 100, 1100, 300), 1280, 720, 1.5) |
| assert "lệch" in s and "phải" in s |
| assert "đáy cách mép dưới 420px" in s and "1.50m" in s |
|
|
|
|
| def test_vlm_turn_capped_to_max(tmp_path): |
| |
| brain = FakeBrain(['{"action":"turn_left","value":90,"unit":"degrees"}']) |
| motion = FakeMotion() |
| ag = NavLoopAgent(brain, FakeFrames(), motion, max_steps=1, |
| log_dir=str(tmp_path), settle_s=0, control="vlm") |
| list(ag.run("scan")) |
| assert len(motion.ran) == 1 |
| assert abs(motion.ran[0].value) <= math.radians(10) + 1e-6 |
|
|
|
|
| def test_search_accumulator_stops_after_full_scan(tmp_path): |
| |
| brain = FakeBrain(['{"action":"turn_left","value":10,"unit":"degrees"}'] * 10) |
| motion = FakeMotion() |
| ag = NavLoopAgent(brain, FakeFrames(), motion, log_dir=str(tmp_path), |
| settle_s=0, control="vlm") |
| ag.search_max_deg = 25 |
| events = list(ag.run("go to the bottle")) |
| assert any("Không tìm thấy mục tiêu" in e.get("message", "") for e in events) |
| assert len(motion.ran) <= 3 |
|
|
|
|
| def test_vlm_trusts_stop_pure_vlm(tmp_path): |
| |
| brain = FakeBrain(['{"action":"stop","is_finished":true,"reasoning":"tới"}']) |
| agent = NavLoopAgent(brain, FakeFrames(), FakeMotion(), log_dir=str(tmp_path), |
| settle_s=0, control="vlm") |
| events = list(agent.run("go to the bottle")) |
| assert _answers(events)[-1]["state"] == "YES" |
|
|