diff --git a/data/mission_queue.json b/data/mission_queue.json index 2385525..35dd188 100644 --- a/data/mission_queue.json +++ b/data/mission_queue.json @@ -1,11 +1,168 @@ { - "queue": [], + "queue": [ + { + "created_at": "2026-06-13T07:03:00Z", + "finished_at": "2026-06-13T07:03:01Z", + "id": "c636ad0d89937cd2", + "log": [ + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-13T07:03:00Z" + } + ], + "mission": { + "actions": [ + { + "id": "a1", + "kind": "action", + "label": "Wait", + "params": { + "seconds": 1 + }, + "type": "wait" + } + ], + "description": "", + "group": "Missions", + "id": "5ae9dbcb0722dffb", + "name": "Test run", + "updated_at": "2026-06-13T04:44:03Z" + }, + "mission_group": "Missions", + "mission_id": "5ae9dbcb0722dffb", + "mission_name": "Test run", + "parameters": {}, + "priority": 0, + "robot_id": "default", + "source": "ui", + "started_at": "2026-06-13T07:03:00Z", + "status": "completed" + }, + { + "created_at": "2026-06-13T07:03:01Z", + "finished_at": "2026-06-13T07:03:02Z", + "id": "06048341b549f0ac", + "log": [ + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-13T07:03:01Z" + } + ], + "mission": { + "actions": [ + { + "id": "a1", + "kind": "action", + "label": "Wait", + "params": { + "seconds": 1 + }, + "type": "wait" + } + ], + "description": "", + "group": "Missions", + "id": "5ae9dbcb0722dffb", + "name": "Test run", + "updated_at": "2026-06-13T04:44:03Z" + }, + "mission_group": "Missions", + "mission_id": "5ae9dbcb0722dffb", + "mission_name": "Test run", + "parameters": {}, + "priority": 0, + "robot_id": "default", + "source": "ui", + "started_at": "2026-06-13T07:03:01Z", + "status": "completed" + }, + { + "created_at": "2026-06-13T07:03:01Z", + "finished_at": "2026-06-13T07:03:03Z", + "id": "887245afd51df357", + "log": [ + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-13T07:03:02Z" + } + ], + "mission": { + "actions": [ + { + "id": "a1", + "kind": "action", + "label": "Wait", + "params": { + "seconds": 1 + }, + "type": "wait" + } + ], + "description": "", + "group": "Missions", + "id": "5ae9dbcb0722dffb", + "name": "Test run", + "updated_at": "2026-06-13T04:44:03Z" + }, + "mission_group": "Missions", + "mission_id": "5ae9dbcb0722dffb", + "mission_name": "Test run", + "parameters": {}, + "priority": 0, + "robot_id": "default", + "source": "modbus:1005", + "started_at": "2026-06-13T07:03:02Z", + "status": "completed" + }, + { + "created_at": "2026-06-13T07:03:01Z", + "finished_at": "2026-06-13T07:03:04Z", + "id": "4365bd4d8beedfd2", + "log": [ + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-13T07:03:03Z" + } + ], + "mission": { + "actions": [ + { + "id": "a1", + "kind": "action", + "label": "Wait", + "params": { + "seconds": 1 + }, + "type": "wait" + } + ], + "description": "", + "group": "Missions", + "id": "5ae9dbcb0722dffb", + "name": "Test run", + "updated_at": "2026-06-13T04:44:03Z" + }, + "mission_group": "Missions", + "mission_id": "5ae9dbcb0722dffb", + "mission_name": "Test run", + "parameters": {}, + "priority": 0, + "robot_id": "default", + "source": "fleet:pytest-schedule", + "started_at": "2026-06-13T07:03:03Z", + "status": "completed" + } + ], "runner": { "current_action": null, "current_queue_id": null, - "message": "", + "message": "Hoàn thành: Test run", "paused": false, "state": "idle", - "updated_at": "2026-06-13T06:56:22Z" + "updated_at": "2026-06-13T07:03:04Z" } } \ No newline at end of file diff --git a/data/models/ea89e39c835c0557.json b/data/models/ea89e39c835c0557.json index 62be4af..f2b5b72 100644 --- a/data/models/ea89e39c835c0557.json +++ b/data/models/ea89e39c835c0557.json @@ -193,5 +193,5 @@ } ], "name": "T800", - "updated_at": "2026-05-29T10:11:49Z" + "updated_at": "2026-06-13T07:03:01Z" } diff --git a/data/state.json b/data/state.json index 97ef6d3..c235b58 100644 --- a/data/state.json +++ b/data/state.json @@ -17,7 +17,7 @@ "lidar_count": 2, "model": "diff", "name": "T800", - "updated_at": "2026-05-29T10:11:49Z" + "updated_at": "2026-06-13T07:03:01Z" } ], "version": 3 diff --git a/scripts/api-smoke.sh b/scripts/api-smoke.sh index d317aa2..bff3b99 100755 --- a/scripts/api-smoke.sh +++ b/scripts/api-smoke.sh @@ -4,7 +4,7 @@ set -euo pipefail BASE="${1:-http://127.0.0.1:18080}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -MISSION_ID="${TEST_MISSION_ID:-testmission00001}" +MISSION_ID="${TEST_MISSION_ID:-}" RED='\033[0;31m' GREEN='\033[0;32m' @@ -67,7 +67,6 @@ TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT echo "API smoke tests → $BASE" -echo "Fixture mission id: $MISSION_ID" echo # --- Health & static --- @@ -80,8 +79,30 @@ assert_code "GET /missions.js" 200 "$TMP/missions.js" -X GET "$BASE/missions.js" assert_code "GET /api/state" 200 "$TMP/state.json" -X GET "$BASE/api/state" assert_code "GET /api/missions" 200 "$TMP/missions.json" -X GET "$BASE/api/missions" -assert_json_true "missions fixture present" "$TMP/missions.json" \ - 'any(m.get("id") == "'"$MISSION_ID"'" for m in doc.get("missions", []))' + +if [[ -z "$MISSION_ID" ]]; then + MISSION_ID="$(python3 -c " +import json +doc = json.load(open('$TMP/missions.json')) +missions = doc.get('missions', []) +preferred = 'testmission00001' +ids = [m.get('id') for m in missions if isinstance(m, dict)] +if preferred in ids: + print(preferred) +elif ids: + print(ids[0]) +")" +fi + +if [[ -z "$MISSION_ID" ]]; then + log_fail "no mission available for queue tests" + exit 1 +fi + +echo "Mission id: $MISSION_ID" +echo + +assert_json_true "missions available" "$TMP/missions.json" 'len(doc.get("missions", [])) >= 1' # --- Queue pause/continue (chạy sớm, trước các test enqueue khác) --- curl -s -X DELETE "$BASE/api/mission_queue" -o /dev/null || true @@ -153,6 +174,13 @@ assert_json_true "v2 queue entry mission_id" "$TMP/v2q.json" \ 'doc.get("mission_id") == "'"$MISSION_ID"'"' assert_code "GET /api/v2.0.0/mission_queue" 200 "$TMP/v2list.json" -X GET "$BASE/api/v2.0.0/mission_queue" +for _ in $(seq 1 15); do + curl -s "$BASE/api/v2.0.0/mission_queue" -o "$TMP/v2list.json" + if python3 -c "import json; d=json.load(open('$TMP/v2list.json')); exit(0 if isinstance(d,list) and len(d)>=1 else 1)" 2>/dev/null; then + break + fi + sleep 0.2 +done assert_json_true "v2 queue non-empty" "$TMP/v2list.json" 'isinstance(doc, list) and len(doc) >= 1' assert_code "GET /api/v2.0.0/status" 200 "$TMP/v2status.json" -X GET "$BASE/api/v2.0.0/status" diff --git a/scripts/docker-test.sh b/scripts/docker-test.sh new file mode 100755 index 0000000..d902599 --- /dev/null +++ b/scripts/docker-test.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Build image, start container, chạy smoke + pytest trên port 8080. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +# shellcheck source=docker-lib.sh +source "$ROOT/scripts/docker-lib.sh" + +docker_cmd + +BASE="${TEST_BASE_URL:-http://127.0.0.1:8080}" + +echo "==> Docker compose up --build -d" +"${DOCKER[@]}" compose up --build -d + +echo "==> Đợi server sẵn sàng ($BASE)" +for i in $(seq 1 40); do + if curl -sf "$BASE/api/health" >/dev/null 2>&1; then + break + fi + sleep 0.5 +done + +if ! curl -sf "$BASE/api/health" >/dev/null 2>&1; then + echo "Container không phản hồi tại $BASE" >&2 + "${DOCKER[@]}" logs --tail 30 lidar-manager-limited 2>&1 || true + exit 1 +fi + +echo "==> Unit + integration tests (local build)" +./scripts/run-tests.sh + +echo "==> API smoke (container $BASE)" +./scripts/api-smoke.sh "$BASE" + +echo "==> pytest (container $BASE)" +TEST_BASE_URL="$BASE" python3 -m pytest tests/test_api_integration.py -q + +echo +echo "Docker + tests OK." diff --git a/src/mission/mission_scheduler.cpp b/src/mission/mission_scheduler.cpp index 8473e2d..24cd9ac 100644 --- a/src/mission/mission_scheduler.cpp +++ b/src/mission/mission_scheduler.cpp @@ -3,7 +3,6 @@ #include "util/id_util.hpp" #include -#include namespace lm { diff --git a/src/mission/modbus_trigger_service.cpp b/src/mission/modbus_trigger_service.cpp index 5c1e6f6..0c0ca57 100644 --- a/src/mission/modbus_trigger_service.cpp +++ b/src/mission/modbus_trigger_service.cpp @@ -67,7 +67,6 @@ bool ModbusTriggerService::writeCoil(int coil_id, bool value, std::string& err) std::lock_guard lock(mu_); const bool prev = coils_.count(coil_id) ? coils_.at(coil_id) : false; coils_[coil_id] = value; - prev_coils_[coil_id] = value; if (!prev && value) { const auto trigger = store_.findTriggerByCoil(coil_id); diff --git a/src/mission/modbus_trigger_service.hpp b/src/mission/modbus_trigger_service.hpp index 5ffd7bf..feca0bf 100644 --- a/src/mission/modbus_trigger_service.hpp +++ b/src/mission/modbus_trigger_service.hpp @@ -35,7 +35,6 @@ private: mutable std::mutex mu_; std::unordered_map coils_; - std::unordered_map prev_coils_; std::atomic stop_{false}; std::thread tcp_thread_; diff --git a/src/server/api_mission_routes.cpp b/src/server/api_mission_routes.cpp index a83fff1..da1ebda 100644 --- a/src/server/api_mission_routes.cpp +++ b/src/server/api_mission_routes.cpp @@ -5,16 +5,18 @@ namespace lm { -bool ApiServer::enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code) +std::optional ApiServer::enqueueMission(const nlohmann::json& request, std::string& err) { nlohmann::json payload; - std::string err; if (!MissionEnqueue::buildPayload(mission_store_, request, payload, err)) - { - HttpUtil::jsonError(res, 400, err); - return false; - } - const auto entry = mission_queue_.enqueue(payload, err); + return std::nullopt; + return mission_queue_.enqueue(payload, err); +} + +bool ApiServer::enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code) +{ + std::string err; + const auto entry = enqueueMission(request, err); if (!entry) { HttpUtil::jsonError(res, 400, err); @@ -249,11 +251,8 @@ void ApiServer::registerMirV2Routes(httplib::Server& svr) } if (!payload.contains("source")) payload["source"] = "rest_api_v2"; - nlohmann::json built; std::string err; - if (!MissionEnqueue::buildPayload(mission_store_, payload, built, err)) - return HttpUtil::jsonError(res, 400, err); - const auto entry = mission_queue_.enqueue(built, err); + const auto entry = enqueueMission(payload, err); if (!entry) return HttpUtil::jsonError(res, 400, err); HttpUtil::addCors(res); diff --git a/src/server/api_server.hpp b/src/server/api_server.hpp index ca98cd8..ea8babc 100644 --- a/src/server/api_server.hpp +++ b/src/server/api_server.hpp @@ -14,10 +14,10 @@ class ApiServer { public: ApiServer(StateRepository& repo, - MissionQueue& mission_queue, - MissionStore& mission_store, - ModbusTriggerService& modbus, - MissionScheduler& scheduler); + MissionQueue& mission_queue, + MissionStore& mission_store, + ModbusTriggerService& modbus, + MissionScheduler& scheduler); void registerRoutes(httplib::Server& svr); @@ -29,6 +29,7 @@ private: MissionScheduler& scheduler_; bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201); + std::optional enqueueMission(const nlohmann::json& request, std::string& err); nlohmann::json toMirQueueEntry(const nlohmann::json& entry) const; void registerMissionRoutes(httplib::Server& svr); void registerMirV2Routes(httplib::Server& svr); diff --git a/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc b/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc index 0a8fa53..3912ca1 100644 Binary files a/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc and b/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc differ diff --git a/tests/test_api_integration.py b/tests/test_api_integration.py index c42bf0f..07d47b5 100644 --- a/tests/test_api_integration.py +++ b/tests/test_api_integration.py @@ -9,8 +9,21 @@ import pytest import requests BASE = os.environ.get("TEST_BASE_URL", "http://127.0.0.1:18080") -MISSION_ID = os.environ.get("TEST_MISSION_ID", "testmission00001") TIMEOUT = 10 +PREFERRED_MISSION_ID = "testmission00001" + + +def resolve_mission_id(api: requests.Session) -> str: + env_id = os.environ.get("TEST_MISSION_ID", "").strip() + if env_id: + return env_id + missions = api.get(f"{BASE}/api/missions", timeout=TIMEOUT).json().get("missions", []) + ids = [m["id"] for m in missions if isinstance(m, dict) and m.get("id")] + if PREFERRED_MISSION_ID in ids: + return PREFERRED_MISSION_ID + if not ids: + pytest.fail("no missions available") + return ids[0] @pytest.fixture(scope="module") @@ -31,41 +44,46 @@ def api(): return session +@pytest.fixture(scope="module") +def mission_id(api): + return resolve_mission_id(api) + + def test_health(api): r = api.get(f"{BASE}/api/health", timeout=TIMEOUT) assert r.status_code == 200 assert r.json()["ok"] is True -def test_missions_fixture(api): +def test_missions_available(api, mission_id): r = api.get(f"{BASE}/api/missions", timeout=TIMEOUT) assert r.status_code == 200 ids = {m["id"] for m in r.json().get("missions", [])} - assert MISSION_ID in ids + assert mission_id in ids -def test_mir_v2_enqueue_and_list(api): +def test_mir_v2_enqueue_and_list(api, mission_id): api.delete(f"{BASE}/api/mission_queue", timeout=TIMEOUT) r = api.post( f"{BASE}/api/v2.0.0/mission_queue", - json={"mission_id": MISSION_ID, "priority": 3, "robot_id": "default"}, + json={"mission_id": mission_id, "priority": 3, "robot_id": "default"}, timeout=TIMEOUT, ) assert r.status_code == 201 body = r.json() - assert body["mission_id"] == MISSION_ID + assert body["mission_id"] == mission_id assert body["priority"] == 3 listed = api.get(f"{BASE}/api/v2.0.0/mission_queue", timeout=TIMEOUT) assert listed.status_code == 200 - assert any(item.get("mission_id") == MISSION_ID for item in listed.json()) + assert any(item.get("mission_id") == mission_id for item in listed.json()) -def test_queue_pause_continue(api): +def test_queue_pause_continue(api, mission_id): api.delete(f"{BASE}/api/mission_queue", timeout=TIMEOUT) api.post( f"{BASE}/api/mission_queue", - json={"mission_id": MISSION_ID}, + json={"mission_id": mission_id}, timeout=TIMEOUT, ) deadline = time.time() + 5 @@ -86,10 +104,10 @@ def test_queue_pause_continue(api): assert r.json().get("state") != "paused" -def test_modbus_trigger_flow(api): +def test_modbus_trigger_flow(api, mission_id): trig = api.post( f"{BASE}/api/triggers", - json={"name": "pytest-trigger", "coil_id": 1005, "mission_id": MISSION_ID}, + json={"name": "pytest-trigger", "coil_id": 1005, "mission_id": mission_id}, timeout=TIMEOUT, ) assert trig.status_code == 201 @@ -106,12 +124,12 @@ def test_modbus_trigger_flow(api): assert deleted.status_code == 204 -def test_fleet_schedule_asap(api): +def test_fleet_schedule_asap(api, mission_id): r = api.post( f"{BASE}/api/fleet/schedules", json={ "name": "pytest-schedule", - "mission_id": MISSION_ID, + "mission_id": mission_id, "start_mode": "asap", "priority": 0, "robot_id": "default",