diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0d5c933 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Test + +on: + push: + paths: + - "src/**" + - "tests/**" + - "www/**" + - "CMakeLists.txt" + - "scripts/**" + pull_request: + paths: + - "src/**" + - "tests/**" + - "www/**" + - "CMakeLists.txt" + - "scripts/**" + +jobs: + test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends build-essential cmake curl python3 python3-pip + + - name: Run test suite + run: | + chmod +x scripts/run-tests.sh scripts/api-smoke.sh + ./scripts/run-tests.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index 511c316..db2baf4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,3 +62,46 @@ target_include_directories(lidar_manager_web SYSTEM PRIVATE target_compile_definitions(lidar_manager_web PRIVATE _DEFAULT_SOURCE ) + +option(BUILD_TESTING "Build unit and integration test helpers" ON) + +if(BUILD_TESTING) + enable_testing() + include(FetchContent) + FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.tar.gz + ) + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(googletest) + + set(LM_TEST_LIB_SOURCES + src/util/file_util.cpp + src/util/string_util.cpp + src/util/id_util.cpp + src/mission/mission_store.cpp + src/mission/mission_enqueue.cpp + src/validation/sensor_validator.cpp + ) + + add_executable(lidar_manager_tests + tests/test_mission_enqueue.cpp + tests/test_mission_store.cpp + tests/test_sensor_validator.cpp + ${LM_TEST_LIB_SOURCES} + ) + + target_include_directories(lidar_manager_tests PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + ) + target_include_directories(lidar_manager_tests SYSTEM PRIVATE + "${nlohmann_json_SOURCE_DIR}/single_include" + ) + target_compile_definitions(lidar_manager_tests PRIVATE + TEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/data" + ) + target_link_libraries(lidar_manager_tests PRIVATE GTest::gtest_main) + include(GoogleTest) + gtest_discover_tests(lidar_manager_tests WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) + add_test(NAME unit COMMAND lidar_manager_tests) +endif() diff --git a/README.md b/README.md index d186539..099f501 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,36 @@ cat /proc/meminfo | head ``` ```bash -# htop / stats từ ngoài (không cần vào shell) sudo ./scripts/docker-htop.sh sudo ./scripts/docker-stats.sh ``` +## Test tự động + +Chạy toàn bộ: unit C++ (GTest), API smoke (`curl`), pytest integration. + +```bash +cd /home/robotics/RD/Test3 +chmod +x scripts/run-tests.sh scripts/api-smoke.sh +./scripts/run-tests.sh +``` + +Chỉ unit test C++: + +```bash +cmake -S . -B build -DBUILD_TESTING=ON +cmake --build build -j +ctest --test-dir build --output-on-failure +``` + +Chỉ API smoke (server đang chạy, dùng fixture `tests/fixtures/data/`): + +```bash +./build/lidar_manager_web 18080 www tests/fixtures/data/state.json & +./scripts/api-smoke.sh http://127.0.0.1:18080 +``` + +Fixture mission id mặc định: `testmission00001` (`tests/fixtures/data/missions.json`). + +CI: GitHub Actions workflow `.github/workflows/test.yml`. + diff --git a/Testing/Temporary/CTestCostData.txt b/Testing/Temporary/CTestCostData.txt new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/Testing/Temporary/CTestCostData.txt @@ -0,0 +1 @@ +--- diff --git a/Testing/Temporary/LastTest.log b/Testing/Temporary/LastTest.log new file mode 100644 index 0000000..3c22f24 --- /dev/null +++ b/Testing/Temporary/LastTest.log @@ -0,0 +1,3 @@ +Start testing: Jun 13 13:41 +07 +---------------------------------------------------------- +End testing: Jun 13 13:41 +07 diff --git a/scripts/api-smoke.sh b/scripts/api-smoke.sh new file mode 100755 index 0000000..d317aa2 --- /dev/null +++ b/scripts/api-smoke.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# API smoke tests for lidar_manager_web — run against a live server instance. +set -euo pipefail + +BASE="${1:-http://127.0.0.1:18080}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MISSION_ID="${TEST_MISSION_ID:-testmission00001}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' +PASS=0 +FAIL=0 + +log_pass() { echo -e "${GREEN}PASS${NC} $*"; PASS=$((PASS + 1)); } +log_fail() { echo -e "${RED}FAIL${NC} $*"; FAIL=$((FAIL + 1)); } + +json_field() { + python3 - "$1" "$2" <<'PY' +import json, sys +doc = json.loads(open(sys.argv[1]).read()) +path = sys.argv[2].split(".") +cur = doc +for p in path: + cur = cur[p] +print(cur) +PY +} + +http_code() { + curl -s -o "$1" -w '%{http_code}' "${@:2}" +} + +assert_code() { + local name="$1" expect="$2" file="$3" + shift 3 + local code + code="$(http_code "$file" "$@")" + if [[ "$code" == "$expect" ]]; then + log_pass "$name (HTTP $code)" + else + log_fail "$name — expected HTTP $expect, got $code" + [[ -f "$file" ]] && head -c 400 "$file" >&2 || true + echo >&2 + fi +} + +assert_json_true() { + local name="$1" file="$2" expr="$3" + if python3 - "$file" "$expr" <<'PY' +import json, sys +doc = json.loads(open(sys.argv[1]).read()) +env = {"doc": doc, "any": any, "all": all, "len": len, "list": list, "isinstance": isinstance} +ok = eval(sys.argv[2], {"__builtins__": {}}, env) +sys.exit(0 if ok else 1) +PY + then + log_pass "$name" + else + log_fail "$name" + head -c 400 "$file" >&2 || true + echo >&2 + fi +} + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +echo "API smoke tests → $BASE" +echo "Fixture mission id: $MISSION_ID" +echo + +# --- Health & static --- +assert_code "GET /api/health" 200 "$TMP/health.json" -X GET "$BASE/api/health" +assert_json_true "health ok" "$TMP/health.json" 'doc.get("ok") is True' + +assert_code "GET /" 200 "$TMP/index.html" -X GET "$BASE/" +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", []))' + +# --- 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 +curl -s -X POST "$BASE/api/mission_queue" \ + -H 'Content-Type: application/json' \ + -d "{\"mission_id\":\"$MISSION_ID\"}" -o "$TMP/qpost.json" +for _ in $(seq 1 30); do + curl -s "$BASE/api/mission_queue" -o "$TMP/runner_poll.json" + RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")" + if [[ "$RUNNER_STATE" == "running" ]]; then + break + fi + sleep 0.2 +done +if [[ "$RUNNER_STATE" == "running" ]]; then + assert_code "POST /api/mission_queue/pause" 200 "$TMP/pause.json" \ + -X POST "$BASE/api/mission_queue/pause" + assert_json_true "runner paused" "$TMP/pause.json" 'doc.get("state") == "paused"' + assert_code "POST /api/mission_queue/continue" 200 "$TMP/cont.json" \ + -X POST "$BASE/api/mission_queue/continue" + assert_json_true "runner not paused" "$TMP/cont.json" 'doc.get("state") != "paused"' +else + log_fail "runner never reached running (pause test skipped)" +fi + +# --- LiDAR CRUD --- +assert_code "POST /api/lidars" 201 "$TMP/lidar.json" \ + -X POST "$BASE/api/lidars" \ + -H 'Content-Type: application/json' \ + -d '{"name":"smoke-lidar","ip":"192.168.99.1","port":2112}' +LIDAR_ID="$(json_field "$TMP/lidar.json" id 2>/dev/null || echo "")" +if [[ -n "$LIDAR_ID" ]]; then + log_pass "lidar created id=$LIDAR_ID" + PASS=$((PASS + 1)) + assert_code "PUT /api/lidars" 200 "$TMP/lidar_put.json" \ + -X PUT "$BASE/api/lidars/$LIDAR_ID" \ + -H 'Content-Type: application/json' \ + -d '{"name":"smoke-lidar","ip":"192.168.99.2","port":2112}' + assert_code "DELETE /api/lidars" 204 "$TMP/lidar_del.txt" \ + -X DELETE "$BASE/api/lidars/$LIDAR_ID" +else + log_fail "lidar create — no id in response" +fi + +# --- IMU CRUD --- +assert_code "POST /api/imus" 201 "$TMP/imu.json" \ + -X POST "$BASE/api/imus" \ + -H 'Content-Type: application/json' \ + -d '{"name":"smoke-imu","frame_id":"imu_smoke","topic":"/imu/smoke","source":"external"}' +IMU_ID="$(json_field "$TMP/imu.json" id 2>/dev/null || echo "")" +if [[ -n "$IMU_ID" ]]; then + log_pass "imu created id=$IMU_ID" + PASS=$((PASS + 1)) + assert_code "DELETE /api/imus" 204 "$TMP/imu_del.txt" -X DELETE "$BASE/api/imus/$IMU_ID" +else + log_fail "imu create — no id in response" +fi + +# --- Clear queue --- +curl -s -X DELETE "$BASE/api/mission_queue" -o /dev/null || true +curl -s -X DELETE "$BASE/api/v2.0.0/mission_queue" -o /dev/null || true + +# --- MiR v2 enqueue (Cách C REST) --- +assert_code "POST /api/v2.0.0/mission_queue" 201 "$TMP/v2q.json" \ + -X POST "$BASE/api/v2.0.0/mission_queue" \ + -H 'Content-Type: application/json' \ + -d "{\"mission_id\":\"$MISSION_ID\",\"priority\":2,\"robot_id\":\"default\"}" +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" +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" +assert_json_true "v2 status has state_text" "$TMP/v2status.json" '"state_text" in doc' + +# --- Modbus trigger (Cách C) --- +assert_code "POST /api/triggers" 201 "$TMP/trig.json" \ + -X POST "$BASE/api/triggers" \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"smoke-trigger\",\"coil_id\":1001,\"mission_id\":\"$MISSION_ID\"}" +TRIG_ID="$(json_field "$TMP/trig.json" id 2>/dev/null || echo "")" +if [[ -n "$TRIG_ID" ]]; then + log_pass "trigger created id=$TRIG_ID" + PASS=$((PASS + 1)) + assert_code "POST modbus fire coil" 200 "$TMP/fire.json" \ + -X POST "$BASE/api/modbus/coils/1001/trigger" + assert_code "DELETE /api/triggers" 204 "$TMP/trig_del.txt" \ + -X DELETE "$BASE/api/triggers/$TRIG_ID" +else + log_fail "trigger create — no id" +fi + +# --- Fleet schedule --- +assert_code "POST /api/fleet/schedules" 201 "$TMP/sched.json" \ + -X POST "$BASE/api/fleet/schedules" \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"smoke-schedule\",\"mission_id\":\"$MISSION_ID\",\"start_mode\":\"asap\",\"priority\":1}" +SCHED_ID="$(json_field "$TMP/sched.json" id 2>/dev/null || echo "")" +if [[ -n "$SCHED_ID" ]]; then + log_pass "schedule created id=$SCHED_ID" + PASS=$((PASS + 1)) + assert_code "DELETE /api/fleet/schedules" 204 "$TMP/sched_del.txt" \ + -X DELETE "$BASE/api/fleet/schedules/$SCHED_ID" +else + log_fail "schedule create — no id" +fi + +assert_code "GET /api/fleet/robots" 200 "$TMP/robots.json" -X GET "$BASE/api/fleet/robots" +assert_json_true "robots list" "$TMP/robots.json" 'isinstance(doc, list) and len(doc) >= 1' + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..bd5bb60 --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Build unit tests, start lidar_manager_web on a temp data dir, run API smoke + pytest. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PORT="${TEST_PORT:-18080}" +BASE="http://127.0.0.1:${PORT}" +BIN="${ROOT}/build/lidar_manager_web" +DATA_DIR="$(mktemp -d)" +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$DATA_DIR" +} +trap cleanup EXIT + +echo "==> Configure & build (BUILD_TESTING=ON)" +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON +cmake --build build -j + +echo "==> C++ unit tests (GTest)" +./build/lidar_manager_tests + +echo "==> Prepare isolated data directory" +cp -a tests/fixtures/data/. "$DATA_DIR/" +mkdir -p "$DATA_DIR/models" + +echo "==> Start server on port $PORT" +"$BIN" "$PORT" "$ROOT/www" "$DATA_DIR/state.json" >"$DATA_DIR/server.log" 2>&1 & +SERVER_PID=$! + +for i in $(seq 1 30); do + if curl -sf "$BASE/api/health" >/dev/null 2>&1; then + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "Server exited early:" >&2 + cat "$DATA_DIR/server.log" >&2 || true + exit 1 + fi + sleep 0.2 +done + +if ! curl -sf "$BASE/api/health" >/dev/null 2>&1; then + echo "Server did not become ready on $BASE" >&2 + cat "$DATA_DIR/server.log" >&2 || true + exit 1 +fi + +echo "==> API smoke tests" +chmod +x scripts/api-smoke.sh +./scripts/api-smoke.sh "$BASE" + +if command -v python3 >/dev/null 2>&1; then + echo "==> Python integration tests (pytest)" + if ! python3 -c "import pytest" 2>/dev/null; then + python3 -m pip install --user -q -r tests/requirements.txt + fi + TEST_BASE_URL="$BASE" python3 -m pytest tests/test_api_integration.py -q +else + echo "==> Skipping pytest (python3 not found)" +fi + +echo +echo "All tests passed." diff --git a/src/server/api_mission_routes.cpp b/src/server/api_mission_routes.cpp index ace1ff6..18edcf8 100644 --- a/src/server/api_mission_routes.cpp +++ b/src/server/api_mission_routes.cpp @@ -38,12 +38,18 @@ bool ApiServer::enqueueRequest(const nlohmann::json& request, httplib::Response& nlohmann::json ApiServer::toMirQueueEntry(const nlohmann::json& entry) const { - return nlohmann::json{{"id", entry.value("id", 0)}, - {"mission_id", entry.value("mission_id", "")}, - {"state", entry.value("status", "pending")}, - {"message", entry.value("mission_name", "")}, - {"priority", entry.value("priority", 0)}, - {"robot_id", entry.value("robot_id", "default")}}; + nlohmann::json out = nlohmann::json::object(); + if (entry.contains("id")) + out["id"] = entry["id"]; + out["mission_id"] = entry.value("mission_id", std::string("")); + out["state"] = entry.contains("status") ? entry["status"] : nlohmann::json("pending"); + out["message"] = entry.value("mission_name", std::string("")); + if (entry.contains("priority") && entry["priority"].is_number()) + out["priority"] = entry["priority"]; + else + out["priority"] = 0; + out["robot_id"] = entry.value("robot_id", std::string("default")); + return out; } void ApiServer::registerMissionRoutes(httplib::Server& svr) @@ -226,10 +232,14 @@ void ApiServer::registerMirV2Routes(httplib::Server& svr) svr.Get("/api/v2.0.0/mission_queue", [this](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); nlohmann::json out = nlohmann::json::array(); - for (const auto& item : mission_queue_.list()) + const nlohmann::json queue = mission_queue_.list(); + if (queue.is_array()) { - if (item.is_object()) - out.push_back(toMirQueueEntry(item)); + for (const auto& item : queue) + { + if (item.is_object()) + out.push_back(toMirQueueEntry(item)); + } } res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = out.dump(); @@ -248,10 +258,17 @@ void ApiServer::registerMirV2Routes(httplib::Server& svr) } if (!payload.contains("source")) payload["source"] = "rest_api_v2"; - if (!enqueueRequest(payload, res, 201)) - return; - nlohmann::json created = nlohmann::json::parse(res.body); - res.body = toMirQueueEntry(created).dump(); + 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); + if (!entry) + return HttpUtil::jsonError(res, 400, err); + HttpUtil::addCors(res); + res.status = 201; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = toMirQueueEntry(*entry).dump(); }); svr.Delete("/api/v2.0.0/mission_queue", [this](const httplib::Request&, httplib::Response& res) { diff --git a/tests/fixtures/data/missions.json b/tests/fixtures/data/missions.json new file mode 100644 index 0000000..e8eee28 --- /dev/null +++ b/tests/fixtures/data/missions.json @@ -0,0 +1,39 @@ +{ + "dashboard": { + "widgets": [] + }, + "groups": [ + "Missions" + ], + "missions": [ + { + "actions": [ + { + "id": "a1", + "kind": "action", + "label": "Wait", + "params": { + "seconds": 3 + }, + "type": "wait" + } + ], + "description": "Fixture mission for automated tests", + "group": "Missions", + "id": "testmission00001", + "name": "Test Wait", + "updated_at": "2026-06-13T00:00:00Z" + } + ], + "robots": [ + { + "id": "default", + "name": "Robot test", + "online": true, + "serial": "T-001" + } + ], + "schedules": [], + "triggers": [], + "version": 1 +} diff --git a/tests/fixtures/data/state.json b/tests/fixtures/data/state.json new file mode 100644 index 0000000..2d0dd10 --- /dev/null +++ b/tests/fixtures/data/state.json @@ -0,0 +1,5 @@ +{ + "active_layout_id": "", + "layouts": [], + "version": 3 +} diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..1297247 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,2 @@ +pytest>=7.0 +requests>=2.28 diff --git a/tests/test_api_integration.py b/tests/test_api_integration.py new file mode 100644 index 0000000..c42bf0f --- /dev/null +++ b/tests/test_api_integration.py @@ -0,0 +1,145 @@ +"""Integration tests for lidar_manager_web REST API.""" + +from __future__ import annotations + +import os +import time + +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 + + +@pytest.fixture(scope="module") +def api(): + session = requests.Session() + session.headers.update({"Content-Type": "application/json"}) + deadline = time.time() + TIMEOUT + while time.time() < deadline: + try: + r = session.get(f"{BASE}/api/health", timeout=1) + if r.status_code == 200 and r.json().get("ok"): + break + except requests.RequestException: + pass + time.sleep(0.2) + else: + pytest.fail(f"Server not ready at {BASE}") + return session + + +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): + 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 + + +def test_mir_v2_enqueue_and_list(api): + 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"}, + timeout=TIMEOUT, + ) + assert r.status_code == 201 + body = r.json() + 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()) + + +def test_queue_pause_continue(api): + api.delete(f"{BASE}/api/mission_queue", timeout=TIMEOUT) + api.post( + f"{BASE}/api/mission_queue", + json={"mission_id": MISSION_ID}, + timeout=TIMEOUT, + ) + deadline = time.time() + 5 + while time.time() < deadline: + runner = api.get(f"{BASE}/api/mission_queue", timeout=TIMEOUT).json().get("runner", {}) + if runner.get("state") == "running": + break + time.sleep(0.2) + else: + pytest.skip("runner did not enter running state in time") + + r = api.post(f"{BASE}/api/mission_queue/pause", timeout=TIMEOUT) + assert r.status_code == 200 + assert r.json().get("state") == "paused" + + r = api.post(f"{BASE}/api/mission_queue/continue", timeout=TIMEOUT) + assert r.status_code == 200 + assert r.json().get("state") != "paused" + + +def test_modbus_trigger_flow(api): + trig = api.post( + f"{BASE}/api/triggers", + json={"name": "pytest-trigger", "coil_id": 1005, "mission_id": MISSION_ID}, + timeout=TIMEOUT, + ) + assert trig.status_code == 201 + trig_id = trig.json()["id"] + + before = len(api.get(f"{BASE}/api/mission_queue", timeout=TIMEOUT).json()) + fire = api.post(f"{BASE}/api/modbus/coils/1005/trigger", timeout=TIMEOUT) + assert fire.status_code == 200 + + after = api.get(f"{BASE}/api/mission_queue", timeout=TIMEOUT).json() + assert len(after) >= before + + deleted = api.delete(f"{BASE}/api/triggers/{trig_id}", timeout=TIMEOUT) + assert deleted.status_code == 204 + + +def test_fleet_schedule_asap(api): + r = api.post( + f"{BASE}/api/fleet/schedules", + json={ + "name": "pytest-schedule", + "mission_id": MISSION_ID, + "start_mode": "asap", + "priority": 0, + "robot_id": "default", + }, + timeout=TIMEOUT, + ) + assert r.status_code == 201 + sched_id = r.json()["id"] + + deleted = api.delete(f"{BASE}/api/fleet/schedules/{sched_id}", timeout=TIMEOUT) + assert deleted.status_code == 204 + + +def test_lidar_crud(api): + created = api.post( + f"{BASE}/api/lidars", + json={"name": "pytest-lidar", "ip": "10.99.0.1", "port": 2112}, + timeout=TIMEOUT, + ) + assert created.status_code == 201 + lidar_id = created.json()["id"] + + updated = api.put( + f"{BASE}/api/lidars/{lidar_id}", + json={"name": "pytest-lidar", "ip": "10.99.0.2", "port": 2112}, + timeout=TIMEOUT, + ) + assert updated.status_code == 200 + + deleted = api.delete(f"{BASE}/api/lidars/{lidar_id}", timeout=TIMEOUT) + assert deleted.status_code == 204 diff --git a/tests/test_mission_enqueue.cpp b/tests/test_mission_enqueue.cpp new file mode 100644 index 0000000..a4b2453 --- /dev/null +++ b/tests/test_mission_enqueue.cpp @@ -0,0 +1,71 @@ +#include "mission/mission_enqueue.hpp" +#include "mission/mission_store.hpp" + +#include + +#include + +namespace { + +std::filesystem::path fixtureMissionsPath() +{ + return std::filesystem::path(TEST_FIXTURE_DIR) / "missions.json"; +} + +lm::MissionStore makeStore() +{ + const auto dir = std::filesystem::temp_directory_path() / "lm_test_enqueue"; + std::filesystem::create_directories(dir); + const auto path = dir / "missions.json"; + std::filesystem::copy_file(fixtureMissionsPath(), path, std::filesystem::copy_options::overwrite_existing); + return lm::MissionStore(path); +} + +} // namespace + +TEST(MissionEnqueue, NormalizeParametersFromMirArray) +{ + const nlohmann::json params = nlohmann::json::array({{{"id", "pos"}, {"value", "A1"}}, + {{"key", "speed"}, {"value", 0.5}}}); + const nlohmann::json out = lm::MissionEnqueue::normalizeParameters(params); + EXPECT_TRUE(out.is_object()); + EXPECT_EQ(out["pos"], "A1"); + EXPECT_EQ(out["speed"], 0.5); +} + +TEST(MissionEnqueue, NormalizeParametersObjectPassthrough) +{ + const nlohmann::json params = {{"x", 1}}; + const nlohmann::json out = lm::MissionEnqueue::normalizeParameters(params); + EXPECT_EQ(out, params); +} + +TEST(MissionEnqueue, BuildPayloadFromMissionId) +{ + lm::MissionStore store = makeStore(); + nlohmann::json payload; + std::string err; + const nlohmann::json request = {{"mission_id", "testmission00001"}, {"priority", 3}, {"robot_id", "default"}}; + ASSERT_TRUE(lm::MissionEnqueue::buildPayload(store, request, payload, err)) << err; + EXPECT_EQ(payload["mission"]["id"], "testmission00001"); + EXPECT_EQ(payload["priority"], 3); + EXPECT_EQ(payload["robot_id"], "default"); +} + +TEST(MissionEnqueue, BuildPayloadMissingMissionFails) +{ + lm::MissionStore store = makeStore(); + nlohmann::json payload; + std::string err; + const nlohmann::json request = {{"mission_id", "does_not_exist"}}; + EXPECT_FALSE(lm::MissionEnqueue::buildPayload(store, request, payload, err)); + EXPECT_FALSE(err.empty()); +} + +TEST(MissionEnqueue, BuildPayloadRequiresMissionOrId) +{ + lm::MissionStore store = makeStore(); + nlohmann::json payload; + std::string err; + EXPECT_FALSE(lm::MissionEnqueue::buildPayload(store, nlohmann::json::object(), payload, err)); +} diff --git a/tests/test_mission_store.cpp b/tests/test_mission_store.cpp new file mode 100644 index 0000000..9f0dfc1 --- /dev/null +++ b/tests/test_mission_store.cpp @@ -0,0 +1,96 @@ +#include "mission/mission_store.hpp" + +#include + +#include + +namespace { + +class MissionStoreTest : public ::testing::Test +{ +protected: + void SetUp() override + { + dir_ = std::filesystem::temp_directory_path() / "lm_test_store"; + std::filesystem::remove_all(dir_); + std::filesystem::create_directories(dir_); + store_path_ = dir_ / "missions.json"; + std::filesystem::copy_file(std::filesystem::path(TEST_FIXTURE_DIR) / "missions.json", + store_path_, + std::filesystem::copy_options::overwrite_existing); + store_ = std::make_unique(store_path_); + } + + std::filesystem::path dir_; + std::filesystem::path store_path_; + std::unique_ptr store_; +}; + +} // namespace + +TEST_F(MissionStoreTest, FindMissionFromFixture) +{ + const auto mission = store_->findMission("testmission00001"); + ASSERT_TRUE(mission.has_value()); + EXPECT_EQ((*mission)["name"], "Test Wait"); +} + +TEST_F(MissionStoreTest, AddTriggerValidCoil) +{ + std::string err; + const auto trigger = store_->addTrigger( + {{"name", "PLC line 1"}, {"coil_id", 1001}, {"mission_id", "testmission00001"}}, err); + ASSERT_TRUE(trigger.has_value()) << err; + EXPECT_EQ((*trigger)["coil_id"], 1001); +} + +TEST_F(MissionStoreTest, AddTriggerRejectsInvalidCoil) +{ + std::string err; + const auto trigger = + store_->addTrigger({{"name", "bad"}, {"coil_id", 999}, {"mission_id", "testmission00001"}}, err); + EXPECT_FALSE(trigger.has_value()); + EXPECT_FALSE(err.empty()); +} + +TEST_F(MissionStoreTest, AddTriggerRejectsDuplicateCoil) +{ + std::string err; + ASSERT_TRUE(store_->addTrigger( + {{"name", "first"}, {"coil_id", 1002}, {"mission_id", "testmission00001"}}, err) + .has_value()) + << err; + const auto dup = store_->addTrigger( + {{"name", "second"}, {"coil_id", 1002}, {"mission_id", "testmission00001"}}, err); + EXPECT_FALSE(dup.has_value()); +} + +TEST_F(MissionStoreTest, DeleteTrigger) +{ + std::string err; + const auto trigger = store_->addTrigger( + {{"name", "tmp"}, {"coil_id", 1003}, {"mission_id", "testmission00001"}}, err); + ASSERT_TRUE(trigger.has_value()) << err; + const std::string id = (*trigger)["id"].get(); + EXPECT_TRUE(store_->deleteTrigger(id, err)) << err; + EXPECT_FALSE(store_->findTriggerByCoil(1003).has_value()); +} + +TEST_F(MissionStoreTest, AddScheduleAsap) +{ + std::string err; + const auto schedule = store_->addSchedule( + {{"name", "Morning run"}, {"mission_id", "testmission00001"}, {"priority", 5}, {"start_mode", "asap"}}, + err); + ASSERT_TRUE(schedule.has_value()) << err; + EXPECT_EQ((*schedule)["priority"], 5); + EXPECT_EQ((*schedule)["start_mode"], "asap"); +} + +TEST_F(MissionStoreTest, AddScheduleUnknownMissionFails) +{ + std::string err; + const auto schedule = + store_->addSchedule({{"name", "bad"}, {"mission_id", "missing"}, {"start_mode", "asap"}}, err); + EXPECT_FALSE(schedule.has_value()); +} diff --git a/tests/test_sensor_validator.cpp b/tests/test_sensor_validator.cpp new file mode 100644 index 0000000..c971eb4 --- /dev/null +++ b/tests/test_sensor_validator.cpp @@ -0,0 +1,63 @@ +#include "validation/sensor_validator.hpp" + +#include + +TEST(SensorValidator, LidarRequiresNameIpPort) +{ + std::string err; + EXPECT_FALSE(lm::SensorValidator::validateLidarPayload(nlohmann::json::object(), err)); + EXPECT_FALSE(err.empty()); + + err.clear(); + EXPECT_TRUE(lm::SensorValidator::validateLidarPayload( + {{"name", "front"}, {"ip", "192.168.1.10"}, {"port", 2112}}, err)) + << err; +} + +TEST(SensorValidator, LidarPortRange) +{ + std::string err; + EXPECT_FALSE(lm::SensorValidator::validateLidarPayload( + {{"name", "front"}, {"ip", "192.168.1.10"}, {"port", 70000}}, err)); +} + +TEST(SensorValidator, ImuRequiresFrameAndTopic) +{ + std::string err; + EXPECT_FALSE(lm::SensorValidator::validateImuPayload({{"name", "imu1"}}, err)); + + err.clear(); + EXPECT_TRUE(lm::SensorValidator::validateImuPayload( + {{"name", "imu1"}, {"frame_id", "imu_link"}, {"topic", "/imu/data"}}, err)) + << err; +} + +TEST(SensorValidator, ImuInvalidSource) +{ + std::string err; + EXPECT_FALSE(lm::SensorValidator::validateImuPayload( + {{"name", "imu1"}, + {"frame_id", "imu_link"}, + {"topic", "/imu/data"}, + {"source", "invalid_source"}}, + err)); +} + +TEST(SensorValidator, LidarTripletDuplicateDetection) +{ + const nlohmann::json state = { + {"lidars", + nlohmann::json::array({{{"id", "l1"}, {"name", "front"}, {"ip", "10.0.0.1"}, {"port", 2112}}})}}; + EXPECT_TRUE(lm::SensorValidator::lidarTripletExists(state, "front", "10.0.0.1", 2112)); + const std::string exclude = "l1"; + EXPECT_FALSE(lm::SensorValidator::lidarTripletExists(state, "front", "10.0.0.1", 2112, &exclude)); +} + +TEST(SensorValidator, ImuFrameDuplicateDetection) +{ + const nlohmann::json state = { + {"imus", nlohmann::json::array({{{"id", "i1"}, {"frame_id", "base_imu"}}})}}; + EXPECT_TRUE(lm::SensorValidator::imuFrameExists(state, "base_imu")); + const std::string exclude = "i1"; + EXPECT_FALSE(lm::SensorValidator::imuFrameExists(state, "base_imu", &exclude)); +}