feat: ui program to cb connection #26
25
src/control_backend/api/v1/endpoints/program.py
Normal file
25
src/control_backend/api/v1/endpoints/program.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
from control_backend.schemas.program import Program
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/program", status_code=202)
|
||||||
|
async def receive_message(program: Program, request: Request):
|
||||||
|
"""
|
||||||
|
Receives a BehaviorProgram, pydantic checks it.
|
||||||
|
Converts it into real Phase objects.
|
||||||
|
"""
|
||||||
|
logger.debug("Received raw program: %s", program)
|
||||||
|
|
||||||
|
# send away
|
||||||
|
topic = b"program"
|
||||||
|
body = program.model_dump_json().encode()
|
||||||
|
pub_socket = request.app.state.endpoints_pub_socket
|
||||||
|
await pub_socket.send_multipart([topic, body])
|
||||||
|
|
||||||
|
return {"status": "Program parsed"}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi.routing import APIRouter
|
from fastapi.routing import APIRouter
|
||||||
|
|
||||||
from control_backend.api.v1.endpoints import logs, message, robot, sse
|
from control_backend.api.v1.endpoints import logs, message, program, robot, sse
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -11,3 +11,5 @@ api_router.include_router(sse.router, tags=["SSE"])
|
|||||||
api_router.include_router(robot.router, prefix="/robot", tags=["Pings", "Commands"])
|
api_router.include_router(robot.router, prefix="/robot", tags=["Pings", "Commands"])
|
||||||
|
|
||||||
api_router.include_router(logs.router, tags=["Logs"])
|
api_router.include_router(logs.router, tags=["Logs"])
|
||||||
|
|
||||||
|
api_router.include_router(program.router, tags=["Program"])
|
||||||
|
|||||||
38
src/control_backend/schemas/program.py
Normal file
38
src/control_backend/schemas/program.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Norm(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class Goal(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
achieved: bool
|
||||||
|
|
||||||
|
|
||||||
|
class Trigger(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
type: str
|
||||||
|
value: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseData(BaseModel):
|
||||||
|
norms: list[Norm]
|
||||||
|
goals: list[Goal]
|
||||||
|
triggers: list[Trigger]
|
||||||
|
|
||||||
|
|
||||||
|
class Phase(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
nextPhaseId: str
|
||||||
|
phaseData: PhaseData
|
||||||
|
|
||||||
|
|
||||||
|
class Program(BaseModel):
|
||||||
|
phases: list[Phase]
|
||||||
125
test/integration/api/endpoints/test_program_endpoint.py
Normal file
125
test/integration/api/endpoints/test_program_endpoint.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from control_backend.api.v1.endpoints import program
|
||||||
|
from control_backend.schemas.program import Program
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
"""Create a FastAPI app with the /program route and mock socket."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(program.router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
"""Create a TestClient."""
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def make_valid_program_dict():
|
||||||
|
"""Helper to create a valid Program JSON structure."""
|
||||||
|
return {
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"id": "phase1",
|
||||||
|
"name": "basephase",
|
||||||
|
"nextPhaseId": "phase2",
|
||||||
|
"phaseData": {
|
||||||
|
"norms": [{"id": "n1", "name": "norm", "value": "be nice"}],
|
||||||
|
"goals": [
|
||||||
|
{"id": "g1", "name": "goal", "description": "test goal", "achieved": False}
|
||||||
|
],
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"id": "t1",
|
||||||
|
"label": "trigger",
|
||||||
|
"type": "keyword",
|
||||||
|
"value": ["stop", "exit"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_receive_program_success(client):
|
||||||
|
"""Valid Program JSON should be parsed and sent through the socket."""
|
||||||
|
mock_pub_socket = AsyncMock()
|
||||||
|
client.app.state.endpoints_pub_socket = mock_pub_socket
|
||||||
|
|
||||||
|
program_dict = make_valid_program_dict()
|
||||||
|
|
||||||
|
response = client.post("/program", json=program_dict)
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
assert response.json() == {"status": "Program parsed"}
|
||||||
|
|
||||||
|
# Verify socket call
|
||||||
|
mock_pub_socket.send_multipart.assert_awaited_once()
|
||||||
|
args, kwargs = mock_pub_socket.send_multipart.await_args
|
||||||
|
|
||||||
|
assert args[0][0] == b"program"
|
||||||
|
|
||||||
|
sent_bytes = args[0][1]
|
||||||
|
sent_obj = json.loads(sent_bytes.decode())
|
||||||
|
|
||||||
|
expected_obj = Program.model_validate(program_dict).model_dump()
|
||||||
|
assert sent_obj == expected_obj
|
||||||
|
|
||||||
|
|
||||||
|
def test_receive_program_invalid_json(client):
|
||||||
|
"""
|
||||||
|
Invalid JSON (malformed) -> FastAPI never calls endpoint.
|
||||||
|
It returns a 422 Unprocessable Entity.
|
||||||
|
"""
|
||||||
|
mock_pub_socket = AsyncMock()
|
||||||
|
client.app.state.endpoints_pub_socket = mock_pub_socket
|
||||||
|
|
||||||
|
# FastAPI only accepts valid JSON bodies, so send raw string
|
||||||
|
response = client.post("/program", content="{invalid json}")
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
mock_pub_socket.send_multipart.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_receive_program_invalid_deep_structure(client):
|
||||||
|
"""
|
||||||
|
Valid JSON but schema invalid -> Pydantic throws validation error -> 422.
|
||||||
|
"""
|
||||||
|
mock_pub_socket = AsyncMock()
|
||||||
|
client.app.state.endpoints_pub_socket = mock_pub_socket
|
||||||
|
|
||||||
|
# Missing "value" in norms element
|
||||||
|
bad_program = {
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"id": "phase1",
|
||||||
|
"name": "deepfail",
|
||||||
|
"nextPhaseId": "phase2",
|
||||||
|
"phaseData": {
|
||||||
|
"norms": [
|
||||||
|
{"id": "n1", "name": "norm"} # INVALID: missing "value"
|
||||||
|
],
|
||||||
|
"goals": [
|
||||||
|
{"id": "g1", "name": "goal", "description": "desc", "achieved": False}
|
||||||
|
],
|
||||||
|
"triggers": [
|
||||||
|
{"id": "t1", "label": "trigger", "type": "keyword", "value": ["start"]}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/program", json=bad_program)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
mock_pub_socket.send_multipart.assert_not_called()
|
||||||
85
test/integration/schemas/test_ui_program_message.py
Normal file
85
test/integration/schemas/test_ui_program_message.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from control_backend.schemas.program import Goal, Norm, Phase, PhaseData, Program, Trigger
|
||||||
|
|
||||||
|
|
||||||
|
def base_norm() -> Norm:
|
||||||
|
return Norm(
|
||||||
|
id="norm1",
|
||||||
|
name="testNorm",
|
||||||
|
value="you should act nice",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def base_goal() -> Goal:
|
||||||
|
return Goal(
|
||||||
|
id="goal1",
|
||||||
|
name="testGoal",
|
||||||
|
description="you should act nice",
|
||||||
|
achieved=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def base_trigger() -> Trigger:
|
||||||
|
return Trigger(
|
||||||
|
id="trigger1",
|
||||||
|
label="testTrigger",
|
||||||
|
type="keyword",
|
||||||
|
value=["Stop", "Exit"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def base_phase_data() -> PhaseData:
|
||||||
|
return PhaseData(
|
||||||
|
norms=[base_norm()],
|
||||||
|
goals=[base_goal()],
|
||||||
|
triggers=[base_trigger()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def base_phase() -> Phase:
|
||||||
|
return Phase(
|
||||||
|
id="phase1",
|
||||||
|
name="basephase",
|
||||||
|
nextPhaseId="phase2",
|
||||||
|
phaseData=base_phase_data(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def base_program() -> Program:
|
||||||
|
return Program(phases=[base_phase()])
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_program() -> dict:
|
||||||
|
# wrong types inside phases list (not Phase objects)
|
||||||
|
return {
|
||||||
|
"phases": [
|
||||||
|
{"id": "phase1"}, # incomplete
|
||||||
|
{"not_a_phase": True},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_program():
|
||||||
|
program = base_program()
|
||||||
|
validated = Program.model_validate(program)
|
||||||
|
assert isinstance(validated, Program)
|
||||||
|
assert validated.phases[0].phaseData.norms[0].name == "testNorm"
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_deepprogram():
|
||||||
|
program = base_program()
|
||||||
|
validated = Program.model_validate(program)
|
||||||
|
# validate nested components directly
|
||||||
|
phase = validated.phases[0]
|
||||||
|
assert isinstance(phase.phaseData, PhaseData)
|
||||||
|
assert isinstance(phase.phaseData.goals[0], Goal)
|
||||||
|
assert isinstance(phase.phaseData.triggers[0], Trigger)
|
||||||
|
assert isinstance(phase.phaseData.norms[0], Norm)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_program():
|
||||||
|
bad = invalid_program()
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Program.model_validate(bad)
|
||||||
Reference in New Issue
Block a user