create TESTING
This commit is contained in:
33
.github/workflows/test.yml
vendored
Normal file
33
.github/workflows/test.yml
vendored
Normal file
@@ -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
|
||||
@@ -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()
|
||||
|
||||
30
README.md
30
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`.
|
||||
|
||||
|
||||
1
Testing/Temporary/CTestCostData.txt
Normal file
1
Testing/Temporary/CTestCostData.txt
Normal file
@@ -0,0 +1 @@
|
||||
---
|
||||
3
Testing/Temporary/LastTest.log
Normal file
3
Testing/Temporary/LastTest.log
Normal file
@@ -0,0 +1,3 @@
|
||||
Start testing: Jun 13 13:41 +07
|
||||
----------------------------------------------------------
|
||||
End testing: Jun 13 13:41 +07
|
||||
200
scripts/api-smoke.sh
Executable file
200
scripts/api-smoke.sh
Executable file
@@ -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
|
||||
71
scripts/run-tests.sh
Executable file
71
scripts/run-tests.sh
Executable file
@@ -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."
|
||||
@@ -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,11 +232,15 @@ 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())
|
||||
{
|
||||
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) {
|
||||
|
||||
39
tests/fixtures/data/missions.json
vendored
Normal file
39
tests/fixtures/data/missions.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
5
tests/fixtures/data/state.json
vendored
Normal file
5
tests/fixtures/data/state.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"active_layout_id": "",
|
||||
"layouts": [],
|
||||
"version": 3
|
||||
}
|
||||
2
tests/requirements.txt
Normal file
2
tests/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pytest>=7.0
|
||||
requests>=2.28
|
||||
145
tests/test_api_integration.py
Normal file
145
tests/test_api_integration.py
Normal file
@@ -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
|
||||
71
tests/test_mission_enqueue.cpp
Normal file
71
tests/test_mission_enqueue.cpp
Normal file
@@ -0,0 +1,71 @@
|
||||
#include "mission/mission_enqueue.hpp"
|
||||
#include "mission/mission_store.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
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));
|
||||
}
|
||||
96
tests/test_mission_store.cpp
Normal file
96
tests/test_mission_store.cpp
Normal file
@@ -0,0 +1,96 @@
|
||||
#include "mission/mission_store.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
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<lm::MissionStore>(store_path_);
|
||||
}
|
||||
|
||||
std::filesystem::path dir_;
|
||||
std::filesystem::path store_path_;
|
||||
std::unique_ptr<lm::MissionStore> 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<std::string>();
|
||||
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());
|
||||
}
|
||||
63
tests/test_sensor_validator.cpp
Normal file
63
tests/test_sensor_validator.cpp
Normal file
@@ -0,0 +1,63 @@
|
||||
#include "validation/sensor_validator.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user