Compare commits

...

13 commits

Author SHA1 Message Date
f013955bc2 ci(procedure): Detailed CI pipeline
Some checks are pending
Polyglot CI / tests (push) Waiting to run
2026-01-22 11:02:06 +00:00
fb48c58a30 fix(coverage): detailed coverage test filtering 2026-01-22 10:53:09 +00:00
326c31c149 feat(logging): Add logging for REST Controller 2026-01-22 08:56:13 +00:00
49646d97ed ci(e2e): Add deps 2026-01-22 08:48:09 +00:00
ba670223de ci(e2e): Add e2e ci 2026-01-22 08:39:11 +00:00
1a278fdcea docs(README): Add Workflow badge and repo description 2026-01-22 08:19:25 +00:00
aabecad5af ci(deps): Add fd 2026-01-22 08:11:55 +00:00
dc9c372e07 ci(nix): Apply ci devshell 2026-01-22 08:09:22 +00:00
c782934ea7 ci(nix): Add ci-specific devShell 2026-01-22 08:08:32 +00:00
a4ccc16f8f ci(typos): fix typo 2026-01-22 08:03:08 +00:00
b9af4ef5c5 ci(test): Test CI status 2026-01-22 08:02:06 +00:00
ada2a0ceb3 ci(test): Test of CI 2026-01-22 08:01:15 +00:00
4e623fada6 ci(automation): pre-ci setup 2026-01-22 07:50:30 +00:00
21 changed files with 882 additions and 209 deletions

54
.bin/e2e.sh Normal file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -e
echo "[INFO] Starting Go Backend..."
cd drone-black-box && go build -o app main.go
cd ..
./drone-black-box/app &
GO_PID=$!
echo " Go PID: $GO_PID"
echo "[INFO] Starting Java Backend..."
cd ilp-rest-service
./gradlew bootRun &
JAVA_PID=$!
echo " Java PID: $JAVA_PID"
cd ..
cleanup() {
echo "[INFO] Stopping services..."
kill $GO_PID || true
kill $JAVA_PID || true
}
trap cleanup EXIT
echo "[INFO] Waiting for services to be ready..."
for i in {1..30}; do
if curl -s http://localhost:8080/actuator/health > /dev/null; then
echo "[INFO] Java is UP!"
break
fi
echo "[DEBUG] Waiting for Java..."
sleep 2
done
echo "[INFO] Running Bruno E2E Collection..."
cd ./ilp-rest-service/ilp-cw-api
bru run
echo "[INFO] E2E Tests Passed!"
rm --force drone_black_box.db
rm --force drone_black_box.db-wal
rm --force drone_black_box.db-shm
cleanup

32
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: Polyglot CI
on:
push:
branches: [ "main" ]
paths:
- '**.java'
- '**.go'
- '.justfile'
- '**.js'
- '**.svelte'
- '**.css'
pull_request:
branches: [ "main" ]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v25
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v14
with:
name: mycache
- run: chmod +x ./ilp-rest-service/gradlew
- name: Unit Testing and Integration Testing
run: nix develop .#ci --command just all
- name: E2E Test
run: nix develop .#ci --command just e2e

6
.gitignore vendored
View file

@ -47,3 +47,9 @@ drone-black-box/drone-black-box
drone-black-box/drone_black_box.db* drone-black-box/drone_black_box.db*
*.pdf *.pdf
*.out
*.html
drone_black_box.db*
app

29
.justfile Normal file
View file

@ -0,0 +1,29 @@
all:
@just format
@just static-analysis
@just test
format:
cd ./drone-black-box && go fmt
fd --extension java --exec google-java-format --replace --aosp {}
static-analysis:
cd ./drone-black-box && go vet ./...
test:
cd ./drone-black-box && go test -v ./... -coverprofile=coverage.out
cd ./drone-black-box && go tool cover -html=coverage.out -o coverage.html
rm ./drone-black-box/coverage.out
# Java
cd ./ilp-rest-service/ && ./gradlew check
e2e:
bash .bin/e2e.sh
benchmark:
echo "Make sure the server is running at http://localhost:3000"
oha -z 10s -c 50 -m POST \
-H "Content-Type: application/json" \
-d '{"drone_id": "TEST-DRONE", "latitude": 0.0, "longitude": 0.0, "timestamp": "2025-01-01"}' \
http://localhost:3000/ingest

View file

@ -1,5 +1,9 @@
# Informatics Large Practical Coursework 3 # Informatics Large Practical Coursework 3
This is a temporary repository, if you are viewing on GitHub, just ignore the (randomly generated) repository name.
[![Polyglot CI](https://github.com/js0ny/expert-goggles/actions/workflows/ci.yml/badge.svg)](https://github.com/js0ny/expert-goggles/actions/workflows/ci.yml)
## Installation ## Installation
### Docker Compose ### Docker Compose

View file

@ -125,6 +125,7 @@ func TestIngestBadJSON(t *testing.T) {
// Ensure graceful shutdown path does not hang: start a server and shut it down quickly. // Ensure graceful shutdown path does not hang: start a server and shut it down quickly.
func TestGracefulShutdown(t *testing.T) { func TestGracefulShutdown(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close()
srv := &Server{db: db} srv := &Server{db: db}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", srv.healthHandler) mux.HandleFunc("GET /health", srv.healthHandler)
@ -138,3 +139,75 @@ func TestGracefulShutdown(t *testing.T) {
t.Fatalf("shutdown: %v", err) t.Fatalf("shutdown: %v", err)
} }
} }
func TestCORSAllowedOriginAndPreflight(t *testing.T) {
handled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled = true
w.WriteHeader(http.StatusOK)
})
handler := corsMiddleware(next)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.Header.Set("Origin", "http://localhost:5173")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
t.Fatalf("expected allow-origin header, got %q", got)
}
if !handled {
t.Fatalf("expected handler to be called")
}
preflight := httptest.NewRequest(http.MethodOptions, "/health", nil)
preflight.Header.Set("Origin", "http://localhost:5173")
preflightRec := httptest.NewRecorder()
handler.ServeHTTP(preflightRec, preflight)
if preflightRec.Code != http.StatusNoContent {
t.Fatalf("preflight expected 204, got %d", preflightRec.Code)
}
}
func TestIngestDBError(t *testing.T) {
db := newTestDB(t)
db.Close()
srv := &Server{db: db}
ev := DroneEvent{DroneID: "d1", Latitude: 1.0, Longitude: 2.0, Timestamp: "2025-12-06T00:00:00Z"}
body, _ := json.Marshal(ev)
req := httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body))
rec := httptest.NewRecorder()
srv.ingestHandler(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
}
func TestSnapshotQueryError(t *testing.T) {
db := newTestDB(t)
db.Close()
srv := &Server{db: db}
req := httptest.NewRequest(http.MethodGet, "/snapshot?time=2025-12-06T00:00:00Z", nil)
rec := httptest.NewRecorder()
srv.snapshotHandler(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
}
func TestHealthFailure(t *testing.T) {
db := newTestDB(t)
db.Close()
srv := &Server{db: db}
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
srv.healthHandler(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rec.Code)
}
}

View file

@ -17,29 +17,42 @@
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
}; };
in { ciDeps = with pkgs; [
default = pkgs.mkShell { jdk21
buildInputs = with pkgs; [ gradle
google-java-format
go
just
fd
bruno-cli
];
devDeps = with pkgs; [
vscode-langservers-extracted vscode-langservers-extracted
jdt-language-server jdt-language-server
jless jless
jdk21
gradle
httpie httpie
docker docker
docker-compose docker-compose
newman newman
gron gron
fx fx
google-java-format
oha oha
gopls gopls
go
bun bun
svelte-language-server svelte-language-server
typescript-language-server typescript-language-server
prettier prettier
]; ];
in {
default = pkgs.mkShell {
buildInputs = ciDeps ++ devDeps;
shellHook = ''
export JAVA_HOME=${pkgs.jdk21}
echo "Java: $(java --version | head -n 1)"
'';
};
ci = pkgs.mkShell {
buildInputs = ciDeps;
shellHook = '' shellHook = ''
export JAVA_HOME=${pkgs.jdk21} export JAVA_HOME=${pkgs.jdk21}
echo "Java: $(java --version | head -n 1)" echo "Java: $(java --version | head -n 1)"

View file

@ -2,6 +2,7 @@ plugins {
id 'java' id 'java'
id 'org.springframework.boot' version '3.5.6' id 'org.springframework.boot' version '3.5.6'
id 'io.spring.dependency-management' version '1.1.7' id 'io.spring.dependency-management' version '1.1.7'
id 'jacoco'
} }
group = 'io.github.js0ny' group = 'io.github.js0ny'
@ -26,6 +27,58 @@ dependencies {
} }
jacoco {
toolVersion = "0.8.12"
}
tasks.named('test') { tasks.named('test') {
useJUnitPlatform() useJUnitPlatform()
finalizedBy jacocoTestReport
} }
jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
}
afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/IlpCourseworkApplication.class',
'**/config/*',
'**/data/*',
'**/util/*',
'**/TelemetryService.class'
])
}))
}
}
jacocoTestCoverageVerification {
violationRules {
rule {
element = 'CLASS'
excludes = [
'io.github.js0ny.ilp_coursework.IlpCourseworkApplication',
'**.config.**',
'**.data.**',
'**.util.**',
'io.github.js0ny.ilp_coursework.service.TelemetryService'
]
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.50
}
}
}
}
check.dependsOn jacocoTestCoverageVerification

View file

@ -14,7 +14,7 @@ body:json {
[ [
{ {
"id": 123, "id": 123,
"date": "2025-12-22", "date": "2025-12-25",
"time": "14:30", "time": "14:30",
"requirements": { "requirements": {
"capacity": 0.75, "capacity": 0.75,
@ -28,7 +28,7 @@ body:json {
}, },
{ {
"id": 456, "id": 456,
"date": "2025-12-25", "date": "2025-12-23",
"time": "11:30", "time": "11:30",
"requirements": { "requirements": {
"capacity": 0.75, "capacity": 0.75,

View file

@ -4,13 +4,12 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/** /** Global CORS configuration so the frontend running on a different port can call the REST API. */
* Global CORS configuration so the frontend running on a different port can call the REST API.
*/
@Configuration @Configuration
public class CorsConfig implements WebMvcConfigurer { public class CorsConfig implements WebMvcConfigurer {
private static final String[] ALLOWED_ORIGINS = new String[] { private static final String[] ALLOWED_ORIGINS =
new String[] {
"http://localhost:4173", "http://localhost:4173",
"http://127.0.0.1:4173", "http://127.0.0.1:4173",
"http://localhost:5173", "http://localhost:5173",

View file

@ -8,6 +8,8 @@ import io.github.js0ny.ilp_coursework.data.request.MovementRequest;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest; import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import io.github.js0ny.ilp_coursework.service.GpsCalculationService; import io.github.js0ny.ilp_coursework.service.GpsCalculationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@ -25,6 +27,8 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
public class ApiController { public class ApiController {
private static final Logger log = LoggerFactory.getLogger(ApiController.class);
private final GpsCalculationService gpsService; private final GpsCalculationService gpsService;
/** /**
@ -45,6 +49,7 @@ public class ApiController {
*/ */
@GetMapping("/uid") @GetMapping("/uid")
public String getUid() { public String getUid() {
log.info("GET /api/v1/uid");
return "s2522255"; return "s2522255";
} }
@ -58,6 +63,7 @@ public class ApiController {
public double getDistance(@RequestBody DistanceRequest request) { public double getDistance(@RequestBody DistanceRequest request) {
LngLat position1 = request.position1(); LngLat position1 = request.position1();
LngLat position2 = request.position2(); LngLat position2 = request.position2();
log.info("POST /api/v1/distanceTo position1={} position2={}", position1, position2);
return gpsService.calculateDistance(position1, position2); return gpsService.calculateDistance(position1, position2);
} }
@ -72,6 +78,7 @@ public class ApiController {
public boolean getIsCloseTo(@RequestBody DistanceRequest request) { public boolean getIsCloseTo(@RequestBody DistanceRequest request) {
LngLat position1 = request.position1(); LngLat position1 = request.position1();
LngLat position2 = request.position2(); LngLat position2 = request.position2();
log.info("POST /api/v1/isCloseTo position1={} position2={}", position1, position2);
return gpsService.isCloseTo(position1, position2); return gpsService.isCloseTo(position1, position2);
} }
@ -86,6 +93,7 @@ public class ApiController {
public LngLat getNextPosition(@RequestBody MovementRequest request) { public LngLat getNextPosition(@RequestBody MovementRequest request) {
LngLat start = request.start(); LngLat start = request.start();
Angle angle = new Angle(request.angle()); Angle angle = new Angle(request.angle());
log.info("POST /api/v1/nextPosition start={} angle={}", start, angle);
return gpsService.nextPosition(start, angle); return gpsService.nextPosition(start, angle);
} }
@ -99,6 +107,7 @@ public class ApiController {
public boolean getIsInRegion(@RequestBody RegionCheckRequest request) { public boolean getIsInRegion(@RequestBody RegionCheckRequest request) {
LngLat position = request.position(); LngLat position = request.position();
Region region = request.region(); Region region = request.region();
log.info("POST /api/v1/isInRegion position={} region={}", position, region);
return gpsService.checkIsInRegion(position, region); return gpsService.checkIsInRegion(position, region);
} }
} }

View file

@ -8,6 +8,8 @@ import io.github.js0ny.ilp_coursework.service.DroneAttrComparatorService;
import io.github.js0ny.ilp_coursework.service.DroneInfoService; import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import io.github.js0ny.ilp_coursework.service.PathFinderService; import io.github.js0ny.ilp_coursework.service.PathFinderService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -23,6 +25,8 @@ import java.util.List;
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
public class DroneController { public class DroneController {
private static final Logger log = LoggerFactory.getLogger(DroneController.class);
private final DroneInfoService droneInfoService; private final DroneInfoService droneInfoService;
private final DroneAttrComparatorService droneAttrComparatorService; private final DroneAttrComparatorService droneAttrComparatorService;
private final PathFinderService pathFinderService; private final PathFinderService pathFinderService;
@ -55,6 +59,7 @@ public class DroneController {
*/ */
@GetMapping("/dronesWithCooling/{state}") @GetMapping("/dronesWithCooling/{state}")
public List<String> getDronesWithCoolingCapability(@PathVariable boolean state) { public List<String> getDronesWithCoolingCapability(@PathVariable boolean state) {
log.info("GET /api/v1/dronesWithCooling/{}", state);
return droneInfoService.dronesWithCooling(state); return droneInfoService.dronesWithCooling(state);
} }
@ -68,9 +73,11 @@ public class DroneController {
@GetMapping("/droneDetails/{id}") @GetMapping("/droneDetails/{id}")
public ResponseEntity<Drone> getDroneDetail(@PathVariable String id) { public ResponseEntity<Drone> getDroneDetail(@PathVariable String id) {
try { try {
log.info("GET /api/v1/droneDetails/{}", id);
Drone drone = droneInfoService.droneDetail(id); Drone drone = droneInfoService.droneDetail(id);
return ResponseEntity.ok(drone); return ResponseEntity.ok(drone);
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
log.warn("GET /api/v1/droneDetails/{} not found", id);
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
} }
@ -86,26 +93,35 @@ public class DroneController {
@GetMapping("/queryAsPath/{attrName}/{attrVal}") @GetMapping("/queryAsPath/{attrName}/{attrVal}")
public List<String> getIdByAttrMap( public List<String> getIdByAttrMap(
@PathVariable String attrName, @PathVariable String attrVal) { @PathVariable String attrName, @PathVariable String attrVal) {
log.info("GET /api/v1/queryAsPath/{}/{}", attrName, attrVal);
return droneAttrComparatorService.dronesWithAttribute(attrName, attrVal); return droneAttrComparatorService.dronesWithAttribute(attrName, attrVal);
} }
@PostMapping("/query") @PostMapping("/query")
public List<String> getIdByAttrMapPost(@RequestBody AttrQueryRequest[] attrComparators) { public List<String> getIdByAttrMapPost(@RequestBody AttrQueryRequest[] attrComparators) {
int count = attrComparators == null ? 0 : attrComparators.length;
log.info("POST /api/v1/query comparators={}", count);
return droneAttrComparatorService.dronesSatisfyingAttributes(attrComparators); return droneAttrComparatorService.dronesSatisfyingAttributes(attrComparators);
} }
@PostMapping("/queryAvailableDrones") @PostMapping("/queryAvailableDrones")
public List<String> queryAvailableDrones(@RequestBody MedDispatchRecRequest[] records) { public List<String> queryAvailableDrones(@RequestBody MedDispatchRecRequest[] records) {
int count = records == null ? 0 : records.length;
log.info("POST /api/v1/queryAvailableDrones records={}", count);
return droneInfoService.dronesMatchesRequirements(records); return droneInfoService.dronesMatchesRequirements(records);
} }
@PostMapping("/calcDeliveryPath") @PostMapping("/calcDeliveryPath")
public DeliveryPathResponse calculateDeliveryPath(@RequestBody MedDispatchRecRequest[] record) { public DeliveryPathResponse calculateDeliveryPath(@RequestBody MedDispatchRecRequest[] record) {
int count = record == null ? 0 : record.length;
log.info("POST /api/v1/calcDeliveryPath records={}", count);
return pathFinderService.calculateDeliveryPath(record); return pathFinderService.calculateDeliveryPath(record);
} }
@PostMapping("/calcDeliveryPathAsGeoJson") @PostMapping("/calcDeliveryPathAsGeoJson")
public String calculateDeliveryPathAsGeoJson(@RequestBody MedDispatchRecRequest[] record) { public String calculateDeliveryPathAsGeoJson(@RequestBody MedDispatchRecRequest[] record) {
int count = record == null ? 0 : record.length;
log.info("POST /api/v1/calcDeliveryPathAsGeoJson records={}", count);
return pathFinderService.calculateDeliveryPathAsGeoJson(record); return pathFinderService.calculateDeliveryPathAsGeoJson(record);
} }
} }

View file

@ -1,19 +1,23 @@
package io.github.js0ny.ilp_coursework.controller; package io.github.js0ny.ilp_coursework.controller;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea; import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
import io.github.js0ny.ilp_coursework.data.external.ServicePoint; import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
import io.github.js0ny.ilp_coursework.service.DroneInfoService; import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
public class MapMetaController { public class MapMetaController {
private static final Logger log = LoggerFactory.getLogger(MapMetaController.class);
private final DroneInfoService droneInfoService; private final DroneInfoService droneInfoService;
public MapMetaController(DroneInfoService droneInfoService) { public MapMetaController(DroneInfoService droneInfoService) {
@ -22,11 +26,13 @@ public class MapMetaController {
@GetMapping("/restrictedAreas") @GetMapping("/restrictedAreas")
public List<RestrictedArea> getRestrictedAreas() { public List<RestrictedArea> getRestrictedAreas() {
log.info("GET /api/v1/restrictedAreas");
return droneInfoService.fetchRestrictedAreas(); return droneInfoService.fetchRestrictedAreas();
} }
@GetMapping("/servicePoints") @GetMapping("/servicePoints")
public List<ServicePoint> getServicePoints() { public List<ServicePoint> getServicePoints() {
log.info("GET /api/v1/servicePoints");
return droneInfoService.fetchServicePoints(); return droneInfoService.fetchServicePoints();
} }
} }

View file

@ -1,11 +1,11 @@
package io.github.js0ny.ilp_coursework.data.common; package io.github.js0ny.ilp_coursework.data.common;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
// Corresponding in Go // Corresponding in Go
// //
@ -18,13 +18,10 @@ import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
* } * }
*/ */
public record DroneEvent( public record DroneEvent(String droneId, double latitude, double longitude, String timestamp) {
String droneId,
double latitude, static final int STEP = 1; // seconds between events
double longitude,
String timestamp) {
final static int STEP = 1; // seconds between events
// Helper method that converts from DeliveryPathResponse to List<DroneEvent> // Helper method that converts from DeliveryPathResponse to List<DroneEvent>
public static List<DroneEvent> fromPathResponse(DeliveryPathResponse resp) { public static List<DroneEvent> fromPathResponse(DeliveryPathResponse resp) {
@ -34,11 +31,7 @@ public record DroneEvent(
for (var d : p.deliveries()) { for (var d : p.deliveries()) {
for (var coord : d.flightPath()) { for (var coord : d.flightPath()) {
String timestamp = java.time.Instant.now().toString(); String timestamp = java.time.Instant.now().toString();
events.add(new DroneEvent( events.add(new DroneEvent(id, coord.lat(), coord.lng(), timestamp));
id,
coord.lat(),
coord.lng(),
timestamp));
} }
} }
} }
@ -47,19 +40,15 @@ public record DroneEvent(
// Helper method that converts from DeliveryPathResponse to List<DroneEvent> // Helper method that converts from DeliveryPathResponse to List<DroneEvent>
// with base timestamp // with base timestamp
public static List<DroneEvent> fromPathResponseWithTimestamp(DeliveryPathResponse resp, public static List<DroneEvent> fromPathResponseWithTimestamp(
LocalDateTime baseTimestamp) { DeliveryPathResponse resp, LocalDateTime baseTimestamp) {
List<DroneEvent> events = new java.util.ArrayList<>(); List<DroneEvent> events = new java.util.ArrayList<>();
java.time.LocalDateTime timestamp = baseTimestamp; java.time.LocalDateTime timestamp = baseTimestamp;
for (var p : resp.dronePaths()) { for (var p : resp.dronePaths()) {
String id = String.valueOf(p.droneId()); String id = String.valueOf(p.droneId());
for (var d : p.deliveries()) { for (var d : p.deliveries()) {
for (var coord : d.flightPath()) { for (var coord : d.flightPath()) {
events.add(new DroneEvent( events.add(new DroneEvent(id, coord.lat(), coord.lng(), timestamp.toString()));
id,
coord.lat(),
coord.lng(),
timestamp.toString()));
timestamp = timestamp.plusSeconds(STEP); // Increment timestamp for each event timestamp = timestamp.plusSeconds(STEP); // Increment timestamp for each event
} }
} }
@ -75,14 +64,11 @@ public record DroneEvent(
for (var d : p.deliveries()) { for (var d : p.deliveries()) {
LocalDateTime timestamp = deliveryTimestamps.get(d.deliveryId()); LocalDateTime timestamp = deliveryTimestamps.get(d.deliveryId());
// Fallback to current time if the delivery does not carry a timestamp. // Fallback to current time if the delivery does not carry a timestamp.
System.out.println("Generated event for drone " + id + " at " + timestamp.toString()); System.out.println(
"Generated event for drone " + id + " at " + timestamp.toString());
LocalDateTime current = timestamp != null ? timestamp : LocalDateTime.now(); LocalDateTime current = timestamp != null ? timestamp : LocalDateTime.now();
for (var coord : d.flightPath()) { for (var coord : d.flightPath()) {
events.add(new DroneEvent( events.add(new DroneEvent(id, coord.lat(), coord.lng(), current.toString()));
id,
coord.lat(),
coord.lng(),
current.toString()));
current = current.plusSeconds(STEP); current = current.plusSeconds(STEP);
} }
} }

View file

@ -1,10 +1,6 @@
package io.github.js0ny.ilp_coursework.service; package io.github.js0ny.ilp_coursework.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.LngLat; import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
import io.github.js0ny.ilp_coursework.data.external.Drone; import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea; import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
import io.github.js0ny.ilp_coursework.data.external.ServicePoint; import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
@ -54,12 +50,10 @@ public class DroneInfoService {
/** /**
* Return an array of ids of drones with/without cooling capability * Return an array of ids of drones with/without cooling capability
* *
* <p> * <p>Associated service method with {@code /dronesWithCooling/{state}}
* Associated service method with {@code /dronesWithCooling/{state}}
* *
* @param state determines the capability filtering * @param state determines the capability filtering
* @return if {@code state} is true, return ids of drones with cooling * @return if {@code state} is true, return ids of drones with cooling capability, else without
* capability, else without
* cooling * cooling
* @see * @see
* io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean) * io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean)
@ -82,15 +76,12 @@ public class DroneInfoService {
/** /**
* Return a {@link Drone}-style json data structure with the given {@code id} * Return a {@link Drone}-style json data structure with the given {@code id}
* *
* <p> * <p>Associated service method with {@code /droneDetails/{id}}
* Associated service method with {@code /droneDetails/{id}}
* *
* @param id The id of the drone * @param id The id of the drone
* @return drone json body of given id * @return drone json body of given id
* @throws NullPointerException when cannot fetch available drones from * @throws NullPointerException when cannot fetch available drones from remote
* remote * @throws IllegalArgumentException when drone with given {@code id} cannot be found this should
* @throws IllegalArgumentException when drone with given {@code id} cannot be
* found this should
* lead to a 404 * lead to a 404
* @see io.github.js0ny.ilp_coursework.controller.DroneController#getDroneDetail(String) * @see io.github.js0ny.ilp_coursework.controller.DroneController#getDroneDetail(String)
*/ */
@ -108,12 +99,10 @@ public class DroneInfoService {
} }
/** /**
* Return an array of ids of drones that match all the requirements in the * Return an array of ids of drones that match all the requirements in the medical dispatch
* medical dispatch
* records * records
* *
* <p> * <p>Associated service method with
* Associated service method with
* *
* @param rec array of medical dispatch records * @param rec array of medical dispatch records
* @return List of drone ids that match all the requirements * @return List of drone ids that match all the requirements
@ -135,7 +124,8 @@ public class DroneInfoService {
return drones.stream() return drones.stream()
.filter(d -> d != null && d.capability() != null) .filter(d -> d != null && d.capability() != null)
.filter( .filter(
d -> Arrays.stream(rec) d ->
Arrays.stream(rec)
.filter(r -> r != null && r.requirements() != null) .filter(r -> r != null && r.requirements() != null)
.allMatch(r -> droneMatchesRequirement(d, r))) .allMatch(r -> droneMatchesRequirement(d, r)))
.map(Drone::id) .map(Drone::id)
@ -148,10 +138,8 @@ public class DroneInfoService {
* @param drone the drone to be checked * @param drone the drone to be checked
* @param record the medical dispatch record containing the requirement * @param record the medical dispatch record containing the requirement
* @return true if the drone meets the requirement, false otherwise * @return true if the drone meets the requirement, false otherwise
* @throws IllegalArgumentException when record requirements or drone capability * @throws IllegalArgumentException when record requirements or drone capability is invalid
* is invalid * (capacity and id cannot be null in {@code MedDispathRecDto})
* (capacity and id cannot be null in
* {@code MedDispathRecDto})
*/ */
public boolean droneMatchesRequirement(Drone drone, MedDispatchRecRequest record) { public boolean droneMatchesRequirement(Drone drone, MedDispatchRecRequest record) {
var requirements = record.requirements(); var requirements = record.requirements();
@ -191,13 +179,13 @@ public class DroneInfoService {
* Helper to check if a drone is available at the required date and time * Helper to check if a drone is available at the required date and time
* *
* @param droneId the id of the drone to be checked * @param droneId the id of the drone to be checked
* @param record the medical dispatch record containing the required date and * @param record the medical dispatch record containing the required date and time
* time
* @return true if the drone is available, false otherwise * @return true if the drone is available, false otherwise
*/ */
private boolean checkAvailability(String droneId, MedDispatchRecRequest record) { private boolean checkAvailability(String droneId, MedDispatchRecRequest record) {
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints = restTemplate.getForObject(droneUrl, ServicePointDrones[].class); ServicePointDrones[] servicePoints =
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
LocalDate requiredDate = record.date(); LocalDate requiredDate = record.date();
DayOfWeek requiredDay = requiredDate.getDayOfWeek(); DayOfWeek requiredDay = requiredDate.getDayOfWeek();
@ -216,7 +204,8 @@ public class DroneInfoService {
private LngLat queryServicePointLocationByDroneId(String droneId) { private LngLat queryServicePointLocationByDroneId(String droneId) {
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints = restTemplate.getForObject(droneUrl, ServicePointDrones[].class); ServicePointDrones[] servicePoints =
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
assert servicePoints != null; assert servicePoints != null;
for (var sp : servicePoints) { for (var sp : servicePoints) {
@ -233,7 +222,8 @@ public class DroneInfoService {
private LngLat queryServicePointLocation(int id) { private LngLat queryServicePointLocation(int id) {
URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint); URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
ServicePoint[] servicePoints = restTemplate.getForObject(servicePointUrl, ServicePoint[].class); ServicePoint[] servicePoints =
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null; assert servicePoints != null;
for (var sp : servicePoints) { for (var sp : servicePoints) {
@ -256,22 +246,24 @@ public class DroneInfoService {
public List<RestrictedArea> fetchRestrictedAreas() { public List<RestrictedArea> fetchRestrictedAreas() {
URI restrictedUrl = URI.create(baseUrl).resolve(restrictedAreasEndpoint); URI restrictedUrl = URI.create(baseUrl).resolve(restrictedAreasEndpoint);
RestrictedArea[] restrictedAreas = restTemplate.getForObject(restrictedUrl, RestrictedArea[].class); RestrictedArea[] restrictedAreas =
restTemplate.getForObject(restrictedUrl, RestrictedArea[].class);
assert restrictedAreas != null; assert restrictedAreas != null;
return Arrays.asList(restrictedAreas); return Arrays.asList(restrictedAreas);
} }
public List<ServicePoint> fetchServicePoints() { public List<ServicePoint> fetchServicePoints() {
URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint); URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
ServicePoint[] servicePoints = restTemplate.getForObject(servicePointUrl, ServicePoint[].class); ServicePoint[] servicePoints =
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null; assert servicePoints != null;
return Arrays.asList(servicePoints); return Arrays.asList(servicePoints);
} }
public List<ServicePointDrones> fetchDronesForServicePoints() { public List<ServicePointDrones> fetchDronesForServicePoints() {
URI servicePointDronesUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); URI servicePointDronesUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePointDrones = restTemplate.getForObject(servicePointDronesUrl, ServicePointDrones[] servicePointDrones =
ServicePointDrones[].class); restTemplate.getForObject(servicePointDronesUrl, ServicePointDrones[].class);
assert servicePointDrones != null; assert servicePointDrones != null;
return Arrays.asList(servicePointDrones); return Arrays.asList(servicePointDrones);
} }

View file

@ -23,6 +23,7 @@ import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePa
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
@ -31,7 +32,6 @@ import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.time.LocalDateTime;
/** /**
* Class that handles calculations about deliverypath * Class that handles calculations about deliverypath
@ -44,10 +44,8 @@ import java.time.LocalDateTime;
public class PathFinderService { public class PathFinderService {
/** /**
* Hard stop on how many pathfinding iterations we attempt for a single segment * Hard stop on how many pathfinding iterations we attempt for a single segment before bailing,
* before bailing, * useful for preventing infinite loops caused by precision quirks or unexpected map data.
* useful for preventing infinite loops caused by precision quirks or unexpected
* map data.
* *
* @see #computePath(LngLat, LngLat) * @see #computePath(LngLat, LngLat)
*/ */
@ -67,14 +65,11 @@ public class PathFinderService {
private TelemetryService telemetryService; private TelemetryService telemetryService;
/** /**
* Constructor for PathFinderService. The dependencies are injected by Spring * Constructor for PathFinderService. The dependencies are injected by Spring and the
* and the * constructor pre-computes reference maps used throughout the request lifecycle.
* constructor pre-computes reference maps used throughout the request
* lifecycle.
* *
* @param gpsCalculationService Service handling geometric operations. * @param gpsCalculationService Service handling geometric operations.
* @param droneInfoService Service that exposes drone metadata and * @param droneInfoService Service that exposes drone metadata and capability information.
* capability information.
*/ */
public PathFinderService( public PathFinderService(
GpsCalculationService gpsCalculationService, DroneInfoService droneInfoService) { GpsCalculationService gpsCalculationService, DroneInfoService droneInfoService) {
@ -87,7 +82,8 @@ public class PathFinderService {
this.drones = droneInfoService.fetchAllDrones(); this.drones = droneInfoService.fetchAllDrones();
List<ServicePoint> servicePoints = droneInfoService.fetchServicePoints(); List<ServicePoint> servicePoints = droneInfoService.fetchServicePoints();
List<ServicePointDrones> servicePointAssignments = droneInfoService.fetchDronesForServicePoints(); List<ServicePointDrones> servicePointAssignments =
droneInfoService.fetchDronesForServicePoints();
List<RestrictedArea> restrictedAreas = droneInfoService.fetchRestrictedAreas(); List<RestrictedArea> restrictedAreas = droneInfoService.fetchRestrictedAreas();
this.droneById = this.drones.stream().collect(Collectors.toMap(Drone::id, drone -> drone)); this.droneById = this.drones.stream().collect(Collectors.toMap(Drone::id, drone -> drone));
@ -105,7 +101,8 @@ public class PathFinderService {
} }
} }
this.servicePointLocations = servicePoints.stream() this.servicePointLocations =
servicePoints.stream()
.collect( .collect(
Collectors.toMap( Collectors.toMap(
ServicePoint::id, sp -> new LngLat(sp.location()))); ServicePoint::id, sp -> new LngLat(sp.location())));
@ -114,8 +111,7 @@ public class PathFinderService {
} }
/** /**
* Produce a delivery plan for the provided dispatch records. Deliveries are * Produce a delivery plan for the provided dispatch records. Deliveries are grouped per
* grouped per
* compatible drone and per trip to satisfy each drone move limit. * compatible drone and per trip to satisfy each drone move limit.
* *
* @param records Dispatch records to be fulfilled. * @param records Dispatch records to be fulfilled.
@ -161,14 +157,17 @@ public class PathFinderService {
continue; continue;
} }
List<MedDispatchRecRequest> sortedDeliveries = entry.getValue().stream() List<MedDispatchRecRequest> sortedDeliveries =
entry.getValue().stream()
.sorted( .sorted(
Comparator.comparingDouble( Comparator.comparingDouble(
rec -> gpsCalculationService.calculateDistance( rec ->
gpsCalculationService.calculateDistance(
servicePointLocation, rec.delivery()))) servicePointLocation, rec.delivery())))
.toList(); .toList();
List<List<MedDispatchRecRequest>> trips = splitTrips(sortedDeliveries, drone, servicePointLocation); List<List<MedDispatchRecRequest>> trips =
splitTrips(sortedDeliveries, drone, servicePointLocation);
for (List<MedDispatchRecRequest> trip : trips) { for (List<MedDispatchRecRequest> trip : trips) {
TripResult result = buildTrip(drone, servicePointLocation, trip); TripResult result = buildTrip(drone, servicePointLocation, trip);
@ -246,10 +245,8 @@ public class PathFinderService {
} }
/** /**
* Group dispatch records by their assigned drone, ensuring every record is * Group dispatch records by their assigned drone, ensuring every record is routed through
* routed through * {@link #findBestDrone(MedDispatchRecRequest)} exactly once and discarding invalid entries.
* {@link #findBestDrone(MedDispatchRecRequest)} exactly once and discarding
* invalid entries.
* *
* @param records Dispatch records to be grouped. * @param records Dispatch records to be grouped.
* @return Map keyed by drone ID with the deliveries it should service. * @return Map keyed by drone ID with the deliveries it should service.
@ -268,8 +265,7 @@ public class PathFinderService {
} }
/** /**
* Choose the best drone for the provided record. Currently that equates to * Choose the best drone for the provided record. Currently that equates to picking the closest
* picking the closest
* compatible drone to the delivery location. * compatible drone to the delivery location.
* *
* @param record Dispatch record that needs fulfillment. * @param record Dispatch record that needs fulfillment.
@ -294,7 +290,8 @@ public class PathFinderService {
continue; continue;
} }
double distance = gpsCalculationService.calculateDistance( double distance =
gpsCalculationService.calculateDistance(
servicePointLocation, record.delivery()); servicePointLocation, record.delivery());
if (distance < bestScore) { if (distance < bestScore) {
@ -309,16 +306,14 @@ public class PathFinderService {
} }
/** /**
* Break a sequence of deliveries into several trips that each respect the drone * Break a sequence of deliveries into several trips that each respect the drone move limit. The
* move limit. The
* deliveries should already be ordered by proximity for sensible grouping. * deliveries should already be ordered by proximity for sensible grouping.
* *
* @param deliveries Deliveries assigned to a drone. * @param deliveries Deliveries assigned to a drone.
* @param drone Drone that will service the deliveries. * @param drone Drone that will service the deliveries.
* @param servicePoint Starting and ending point of every trip. * @param servicePoint Starting and ending point of every trip.
* @return Partitioned trips with at least one delivery each. * @return Partitioned trips with at least one delivery each.
* @throws IllegalStateException If a single delivery exceeds the drone's move * @throws IllegalStateException If a single delivery exceeds the drone's move limit.
* limit.
*/ */
private List<List<MedDispatchRecRequest>> splitTrips( private List<List<MedDispatchRecRequest>> splitTrips(
List<MedDispatchRecRequest> deliveries, Drone drone, LngLat servicePoint) { List<MedDispatchRecRequest> deliveries, Drone drone, LngLat servicePoint) {
@ -354,10 +349,8 @@ public class PathFinderService {
} }
/** /**
* Build a single trip for the provided drone, including the entire flight path * Build a single trip for the provided drone, including the entire flight path to every
* to every * delivery and back home. The resulting structure contains the {@link DronePath} representation
* delivery and back home. The resulting structure contains the
* {@link DronePath} representation
* as well as cost and moves consumed. * as well as cost and moves consumed.
* *
* @param drone Drone executing the trip. * @param drone Drone executing the trip.
@ -402,7 +395,8 @@ public class PathFinderService {
flightPlans.add(new Delivery(delivery.id(), flightPath)); flightPlans.add(new Delivery(delivery.id(), flightPath));
} }
float cost = drone.capability().costInitial() float cost =
drone.capability().costInitial()
+ drone.capability().costFinal() + drone.capability().costFinal()
+ (float) (drone.capability().costPerMove() * moves); + (float) (drone.capability().costPerMove() * moves);
@ -412,8 +406,7 @@ public class PathFinderService {
} }
/** /**
* Estimate the number of moves a prospective trip would need by replaying the * Estimate the number of moves a prospective trip would need by replaying the path calculation
* path calculation
* without mutating any persistent state. * without mutating any persistent state.
* *
* @param servicePoint Trip origin. * @param servicePoint Trip origin.
@ -436,8 +429,7 @@ public class PathFinderService {
} }
/** /**
* Build a path between {@code start} and {@code target} by repeatedly moving in * Build a path between {@code start} and {@code target} by repeatedly moving in snapped
* snapped
* increments while avoiding restricted zones. * increments while avoiding restricted zones.
* *
* @param start Start coordinate. * @param start Start coordinate.
@ -470,19 +462,17 @@ public class PathFinderService {
} }
/** /**
* Determine the next position on the path from {@code current} toward * Determine the next position on the path from {@code current} toward {@code target},
* {@code target}, * preferring the snapped angle closest to the desired heading that does not infiltrate a
* preferring the snapped angle closest to the desired heading that does not
* infiltrate a
* restricted region. * restricted region.
* *
* @param current Current coordinate. * @param current Current coordinate.
* @param target Destination coordinate. * @param target Destination coordinate.
* @return Next admissible coordinate or the original point if none can be * @return Next admissible coordinate or the original point if none can be found.
* found.
*/ */
private LngLat nextPosition(LngLat current, LngLat target) { private LngLat nextPosition(LngLat current, LngLat target) {
double desiredAngle = Math.toDegrees( double desiredAngle =
Math.toDegrees(
Math.atan2(target.lat() - current.lat(), target.lng() - current.lng())); Math.atan2(target.lat() - current.lat(), target.lng() - current.lng()));
List<Angle> candidateAngles = buildAngleCandidates(desiredAngle); List<Angle> candidateAngles = buildAngleCandidates(desiredAngle);
for (Angle angle : candidateAngles) { for (Angle angle : candidateAngles) {
@ -495,10 +485,8 @@ public class PathFinderService {
} }
/** /**
* Build a sequence of candidate angles centered on the desired heading, * Build a sequence of candidate angles centered on the desired heading, expanding symmetrically
* expanding symmetrically * clockwise and counter-clockwise to explore alternative headings if the primary path is
* clockwise and counter-clockwise to explore alternative headings if the
* primary path is
* blocked. * blocked.
* *
* @param desiredAngle Bearing in degrees between current and target positions. * @param desiredAngle Bearing in degrees between current and target positions.
@ -533,8 +521,7 @@ public class PathFinderService {
} }
/** /**
* Representation of a computed path segment wrapping the visited positions and * Representation of a computed path segment wrapping the visited positions and the number of
* the number of
* moves taken to traverse them. * moves taken to traverse them.
* *
* @param positions Ordered coordinates that describe the path. * @param positions Ordered coordinates that describe the path.
@ -542,8 +529,7 @@ public class PathFinderService {
*/ */
private record PathSegment(List<LngLat> positions, int moves) { private record PathSegment(List<LngLat> positions, int moves) {
/** /**
* Append the positions from this segment to {@code target}, skipping the first * Append the positions from this segment to {@code target}, skipping the first coordinate
* coordinate
* as it is already represented by the last coordinate in the consumer path. * as it is already represented by the last coordinate in the consumer path.
* *
* @param target Mutable list to append to. * @param target Mutable list to append to.
@ -556,10 +542,8 @@ public class PathFinderService {
} }
/** /**
* Bundle containing the calculated {@link DronePath}, total moves and financial * Bundle containing the calculated {@link DronePath}, total moves and financial cost for a
* cost for a
* single trip. * single trip.
*/ */
private record TripResult(DronePath path, int moves, float cost) { private record TripResult(DronePath path, int moves, float cost) {}
}
} }

View file

@ -1,20 +1,24 @@
package io.github.js0ny.ilp_coursework.service; package io.github.js0ny.ilp_coursework.service;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.CompletableFuture;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.DroneEvent; import io.github.js0ny.ilp_coursework.data.common.DroneEvent;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse; import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@Service @Service
public class TelemetryService { public class TelemetryService {
private static final Logger log = LoggerFactory.getLogger(TelemetryService.class);
private final HttpClient client; private final HttpClient client;
private final ObjectMapper mapper; private final ObjectMapper mapper;
@ -24,7 +28,8 @@ public class TelemetryService {
this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
this.mapper = new ObjectMapper(); this.mapper = new ObjectMapper();
this.BLACKBOX_URL = System.getenv().getOrDefault("BLACKBOX_ENDPOINT", "http://localhost:3000"); this.BLACKBOX_URL =
System.getenv().getOrDefault("BLACKBOX_ENDPOINT", "http://localhost:3000");
} }
public void sendEventAsyncByPathResponse(DeliveryPathResponse resp) { public void sendEventAsyncByPathResponse(DeliveryPathResponse resp) {
@ -34,15 +39,16 @@ public class TelemetryService {
} }
} }
public void sendEventAsyncByPathResponse(DeliveryPathResponse resp, LocalDateTime baseTimestamp) { public void sendEventAsyncByPathResponse(
DeliveryPathResponse resp, LocalDateTime baseTimestamp) {
var events = DroneEvent.fromPathResponseWithTimestamp(resp, baseTimestamp); var events = DroneEvent.fromPathResponseWithTimestamp(resp, baseTimestamp);
for (var event : events) { for (var event : events) {
sendEventAsync(event); sendEventAsync(event);
} }
} }
public void sendEventAsyncByPathResponse(DeliveryPathResponse resp, public void sendEventAsyncByPathResponse(
Map<Integer, LocalDateTime> deliveryTimestamps) { DeliveryPathResponse resp, Map<Integer, LocalDateTime> deliveryTimestamps) {
var events = DroneEvent.fromPathResponseWithTimestamps(resp, deliveryTimestamps); var events = DroneEvent.fromPathResponseWithTimestamps(resp, deliveryTimestamps);
for (var event : events) { for (var event : events) {
sendEventAsync(event); sendEventAsync(event);
@ -50,22 +56,24 @@ public class TelemetryService {
} }
public void sendEventAsync(DroneEvent event) { public void sendEventAsync(DroneEvent event) {
CompletableFuture.runAsync(() -> { CompletableFuture.runAsync(
() -> {
try { try {
String json = mapper.writeValueAsString(event); String json = mapper.writeValueAsString(event);
System.out.println("[INFO] Sending telemetry event: " + json); log.debug("Sending telemetry event: {}", json);
var request = java.net.http.HttpRequest.newBuilder() var request =
java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(BLACKBOX_URL + "/ingest")) .uri(java.net.URI.create(BLACKBOX_URL + "/ingest"))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(json)) .POST(
java.net.http.HttpRequest.BodyPublishers.ofString(
json))
.build(); .build();
client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString()); client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString());
} catch (Exception e) { } catch (Exception e) {
System.err.println("[ERROR] Failed to send telemetry event: " + e.getMessage()); log.error("Failed to send telemetry event: {}", e.getMessage());
} }
}); });
} }
} }

View file

@ -15,6 +15,7 @@ import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.external.Drone; import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest; import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest; import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
import io.github.js0ny.ilp_coursework.service.DroneAttrComparatorService; import io.github.js0ny.ilp_coursework.service.DroneAttrComparatorService;
import io.github.js0ny.ilp_coursework.service.DroneInfoService; import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import io.github.js0ny.ilp_coursework.service.PathFinderService; import io.github.js0ny.ilp_coursework.service.PathFinderService;
@ -409,4 +410,79 @@ public class DroneControllerTest {
.andExpect(content().json(objectMapper.writeValueAsString(expected))); .andExpect(content().json(objectMapper.writeValueAsString(expected)));
} }
} }
@Nested
@DisplayName("POST /calcDeliveryPath")
class PostCalcDeliveryPathTests {
final String API_ENDPOINT = "/api/v1/calcDeliveryPath";
@Test
@DisplayName("Example -> 200 OK")
void postCalcDeliveryPath_shouldReturn200AndJson_whenExampleRequest() throws Exception {
var reqs = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
var delivery = new LngLat(-3.00, 55.121);
var record =
new MedDispatchRecRequest(
123,
LocalDate.parse("2025-12-22"),
LocalTime.parse("14:30"),
reqs,
delivery);
MedDispatchRecRequest[] requestBody = {record};
var flightPath = List.of(new LngLat(-3.0, 55.12), new LngLat(-3.01, 55.13));
var deliveryPath =
new DeliveryPathResponse.DronePath.Delivery(123, flightPath);
var dronePath = new DeliveryPathResponse.DronePath(1, List.of(deliveryPath));
DeliveryPathResponse expected =
new DeliveryPathResponse(
12.5f, 42, new DeliveryPathResponse.DronePath[] {dronePath});
when(pathFinderService.calculateDeliveryPath(any(MedDispatchRecRequest[].class)))
.thenReturn(expected);
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
}
@Nested
@DisplayName("POST /calcDeliveryPathAsGeoJson")
class PostCalcDeliveryPathAsGeoJsonTests {
final String API_ENDPOINT = "/api/v1/calcDeliveryPathAsGeoJson";
@Test
@DisplayName("Example -> 200 OK")
void postCalcDeliveryPathAsGeoJson_shouldReturn200AndGeoJson_whenExampleRequest()
throws Exception {
var reqs = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
var delivery = new LngLat(-3.00, 55.121);
var record =
new MedDispatchRecRequest(
123,
LocalDate.parse("2025-12-22"),
LocalTime.parse("14:30"),
reqs,
delivery);
MedDispatchRecRequest[] requestBody = {record};
String expected = "{\"type\":\"FeatureCollection\",\"features\":[]}";
when(pathFinderService.calculateDeliveryPathAsGeoJson(
any(MedDispatchRecRequest[].class)))
.thenReturn(expected);
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().string(expected));
}
}
} }

View file

@ -0,0 +1,81 @@
package io.github.js0ny.ilp_coursework.controller;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.AltitudeRange;
import io.github.js0ny.ilp_coursework.data.common.LngLatAlt;
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
@WebMvcTest(MapMetaController.class)
public class MapMetaControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@MockitoBean private DroneInfoService droneInfoService;
@Nested
@DisplayName("GET /restrictedAreas")
class RestrictedAreasTests {
@Test
@DisplayName("-> 200 OK")
void getRestrictedAreas_shouldReturn200AndJson() throws Exception {
String endpoint = "/api/v1/restrictedAreas";
RestrictedArea area =
new RestrictedArea(
"Zone A",
1,
new AltitudeRange(0.0, 120.0),
new LngLatAlt[] {
new LngLatAlt(0.0, 0.0, 0.0),
new LngLatAlt(1.0, 0.0, 0.0),
new LngLatAlt(1.0, 1.0, 0.0),
new LngLatAlt(0.0, 1.0, 0.0)
});
List<RestrictedArea> expected = List.of(area);
when(droneInfoService.fetchRestrictedAreas()).thenReturn(expected);
var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk());
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
}
@Nested
@DisplayName("GET /servicePoints")
class ServicePointsTests {
@Test
@DisplayName("-> 200 OK")
void getServicePoints_shouldReturn200AndJson() throws Exception {
String endpoint = "/api/v1/servicePoints";
ServicePoint point = new ServicePoint("Point A", 1, new LngLatAlt(0.1, 0.2, 12.0));
List<ServicePoint> expected = List.of(point);
when(droneInfoService.fetchServicePoints()).thenReturn(expected);
var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk());
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
}
}

View file

@ -0,0 +1,107 @@
package io.github.js0ny.ilp_coursework.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.DroneCapability;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
@ExtendWith(SpringExtension.class)
public class DroneAttrComparatorServiceTest {
private DroneAttrComparatorService service;
private MockRestServiceServer server;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
service = new DroneAttrComparatorService();
RestTemplate restTemplate =
(RestTemplate) ReflectionTestUtils.getField(service, "restTemplate");
server = MockRestServiceServer.createServer(restTemplate);
ReflectionTestUtils.setField(service, "baseUrl", "http://localhost/");
objectMapper = new ObjectMapper();
}
@AfterEach
void tearDown() {
server.verify();
}
@Nested
@DisplayName("dronesWithAttribute(String, String) tests")
class DronesWithAttributeTests {
@Test
@DisplayName("Should return matching ids for boolean attribute")
void dronesWithAttribute_shouldReturnMatchingIds() throws Exception {
Drone[] drones = {
new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)),
new Drone("Drone 2", "2", new DroneCapability(false, true, 5, 500, 1, 1, 1)),
new Drone("Drone 3", "3", new DroneCapability(true, false, 12, 800, 1, 1, 1))
};
String responseBody = objectMapper.writeValueAsString(drones);
server.expect(requestTo("http://localhost/drones"))
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
var result = service.dronesWithAttribute("cooling", "true");
assertThat(result).containsExactly("1", "3");
}
}
@Nested
@DisplayName("dronesSatisfyingAttributes(AttrQueryRequest[]) tests")
class DronesSatisfyingAttributesTests {
@Test
@DisplayName("Should return intersection of all rules")
void dronesSatisfyingAttributes_shouldReturnIntersection() throws Exception {
Drone[] drones = {
new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)),
new Drone("Drone 2", "2", new DroneCapability(true, true, 3, 1000, 1, 1, 1)),
new Drone("Drone 3", "3", new DroneCapability(true, false, 12, 1000, 1, 1, 1))
};
String responseBody = objectMapper.writeValueAsString(drones);
server.expect(requestTo("http://localhost/drones"))
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
server.expect(requestTo("http://localhost/drones"))
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
AttrQueryRequest[] comparators = {
new AttrQueryRequest("capacity", ">", "5"),
new AttrQueryRequest("heating", "=", "true")
};
var result = service.dronesSatisfyingAttributes(comparators);
assertThat(result).containsExactly("1");
}
@Test
@DisplayName("Should return empty list when no comparators")
void dronesSatisfyingAttributes_shouldReturnEmpty_whenNoComparators() {
AttrQueryRequest[] comparators = {};
var result = service.dronesSatisfyingAttributes(comparators);
assertThat(result).isEmpty();
}
}
}

View file

@ -4,11 +4,15 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import io.github.js0ny.ilp_coursework.data.common.AltitudeRange;
import io.github.js0ny.ilp_coursework.data.common.DroneAvailability; import io.github.js0ny.ilp_coursework.data.common.DroneAvailability;
import io.github.js0ny.ilp_coursework.data.common.DroneCapability; import io.github.js0ny.ilp_coursework.data.common.DroneCapability;
import io.github.js0ny.ilp_coursework.data.common.LngLat; import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.LngLatAlt;
import io.github.js0ny.ilp_coursework.data.common.TimeWindow; import io.github.js0ny.ilp_coursework.data.common.TimeWindow;
import io.github.js0ny.ilp_coursework.data.external.Drone; import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones; import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest; import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
@ -230,4 +234,145 @@ public class DroneInfoServiceTest {
assertThat(resultEmpty).containsExactly("1", "2", "3"); assertThat(resultEmpty).containsExactly("1", "2", "3");
} }
} }
@Nested
@DisplayName("droneMatchesRequirement(Drone, MedDispatchRecRequest) tests")
class DroneMatchesRequirementTests {
@Test
@DisplayName("Should throw when requirements are null")
void droneMatchesRequirement_shouldThrow_whenRequirementsNull() {
Drone drone =
new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1));
MedDispatchRecRequest record =
new MedDispatchRecRequest(
1, LocalDate.now(), LocalTime.of(9, 0), null, new LngLat(0, 0));
assertThatThrownBy(() -> droneInfoService.droneMatchesRequirement(drone, record))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("requirements cannot be null");
}
@Test
@DisplayName("Should throw when drone capability is null")
void droneMatchesRequirement_shouldThrow_whenCapabilityNull() {
Drone drone = new Drone("Drone 1", "1", null);
MedDispatchRecRequest record =
new MedDispatchRecRequest(
1,
LocalDate.now(),
LocalTime.of(9, 0),
new MedDispatchRecRequest.MedRequirement(1, false, false, 10),
new LngLat(0, 0));
assertThatThrownBy(() -> droneInfoService.droneMatchesRequirement(drone, record))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("drone capability cannot be null");
}
}
@Nested
@DisplayName("fetchAllDrones() tests")
class FetchAllDronesTests {
@Test
@DisplayName("Should return list when API returns drones")
void fetchAllDrones_shouldReturnList_whenApiReturnsDrones() {
Drone[] drones = getMockDrones();
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(drones);
List<Drone> result = droneInfoService.fetchAllDrones();
assertThat(result).hasSize(3);
assertThat(result.get(0).id()).isEqualTo("1");
}
@Test
@DisplayName("Should return empty list when API returns null")
void fetchAllDrones_shouldReturnEmptyList_whenApiReturnsNull() {
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(null);
List<Drone> result = droneInfoService.fetchAllDrones();
assertThat(result).isEmpty();
}
}
@Nested
@DisplayName("fetchRestrictedAreas() tests")
class FetchRestrictedAreasTests {
@Test
@DisplayName("Should return restricted areas")
void fetchRestrictedAreas_shouldReturnList() {
RestrictedArea[] areas = {
new RestrictedArea(
"Zone A",
1,
new AltitudeRange(0, 100),
new LngLatAlt[] {
new LngLatAlt(0, 0, 0),
new LngLatAlt(1, 0, 0),
new LngLatAlt(1, 1, 0)
})
};
when(restTemplate.getForObject(
URI.create(baseUrl + "restricted-areas"), RestrictedArea[].class))
.thenReturn(areas);
List<RestrictedArea> result = droneInfoService.fetchRestrictedAreas();
assertThat(result).hasSize(1);
assertThat(result.get(0).name()).isEqualTo("Zone A");
}
}
@Nested
@DisplayName("fetchServicePoints() tests")
class FetchServicePointsTests {
@Test
@DisplayName("Should return service points")
void fetchServicePoints_shouldReturnList() {
ServicePoint[] points = {
new ServicePoint("Point A", 1, new LngLatAlt(0.1, 0.2, 3.0))
};
when(restTemplate.getForObject(
URI.create(baseUrl + "service-points"), ServicePoint[].class))
.thenReturn(points);
List<ServicePoint> result = droneInfoService.fetchServicePoints();
assertThat(result).hasSize(1);
assertThat(result.get(0).name()).isEqualTo("Point A");
}
}
@Nested
@DisplayName("fetchDronesForServicePoints() tests")
class FetchDronesForServicePointsTests {
@Test
@DisplayName("Should return service point drones")
void fetchDronesForServicePoints_shouldReturnList() {
TimeWindow[] timeWindows = {
new TimeWindow(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.of(17, 0))
};
DroneAvailability drone1Avail = new DroneAvailability("1", timeWindows);
ServicePointDrones spd =
new ServicePointDrones(1, new DroneAvailability[] {drone1Avail});
ServicePointDrones[] servicePointDrones = {spd};
when(restTemplate.getForObject(
URI.create(baseUrl + "drones-for-service-points"),
ServicePointDrones[].class))
.thenReturn(servicePointDrones);
List<ServicePointDrones> result = droneInfoService.fetchDronesForServicePoints();
assertThat(result).hasSize(1);
assertThat(result.get(0).servicePointId()).isEqualTo(1);
}
}
} }