chore(format): format to google format

This commit is contained in:
js0ny 2025-11-27 13:59:29 +00:00
parent d6e426d1e3
commit 449c81a375
35 changed files with 929 additions and 1138 deletions

View file

@ -0,0 +1,39 @@
meta {
name: Complex copy copy
type: http
seq: 5
}
post {
url: {{API_BASE}}/calcDeliveryPath
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.17,
"lat": 55.9
}
}
]
}
assert {
res.status: eq 200
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,39 @@
meta {
name: Complex copy
type: http
seq: 4
}
post {
url: {{API_BASE}}/calcDeliveryPathAsGeoJson
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.17,
"lat": 55.9
}
}
]
}
assert {
res.status: eq 200
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -7,6 +7,7 @@ import io.github.js0ny.ilp_coursework.data.request.DistanceRequest;
import io.github.js0ny.ilp_coursework.data.request.MovementRequest; 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.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;
@ -15,10 +16,10 @@ import org.springframework.web.bind.annotation.RestController;
/** /**
* Main REST Controller for the ILP Coursework 1 application. * Main REST Controller for the ILP Coursework 1 application.
* <p> *
* This class handles incoming HTTP requests for the API under {@code /api/v1} path (defined in CW1) * <p>This class handles incoming HTTP requests for the API under {@code /api/v1} path (defined in
* This is responsible for mapping requests to the appropriate service method and returning the results as responses. * CW1) This is responsible for mapping requests to the appropriate service method and returning the
* The business logic is delegated to {@link GpsCalculationService} * results as responses. The business logic is delegated to {@link GpsCalculationService}
*/ */
@RestController @RestController
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
@ -27,9 +28,11 @@ public class ApiController {
private final GpsCalculationService gpsService; private final GpsCalculationService gpsService;
/** /**
* Constructor of the {@code ApiController} with the business logic dependency {@code GpsCalculationService} * Constructor of the {@code ApiController} with the business logic dependency {@code
* GpsCalculationService}
* *
* @param gpsService The service component that contains all business logic, injected by Spring's DI. * @param gpsService The service component that contains all business logic, injected by
* Spring's DI.
*/ */
public ApiController(GpsCalculationService gpsService) { public ApiController(GpsCalculationService gpsService) {
this.gpsService = gpsService; this.gpsService = gpsService;
@ -62,7 +65,8 @@ public class ApiController {
* Handles POST requests to check if the two coordinates are close to each other * Handles POST requests to check if the two coordinates are close to each other
* *
* @param request A {@link DistanceRequest} containing the two coordinates * @param request A {@link DistanceRequest} containing the two coordinates
* @return {@code true} if the distance is less than the predefined threshold, {@code false} otherwise * @return {@code true} if the distance is less than the predefined threshold, {@code false}
* otherwise
*/ */
@PostMapping("/isCloseTo") @PostMapping("/isCloseTo")
public boolean getIsCloseTo(@RequestBody DistanceRequest request) { public boolean getIsCloseTo(@RequestBody DistanceRequest request) {
@ -74,7 +78,8 @@ public class ApiController {
/** /**
* Handles POST requests to get the next position after an angle of movement * Handles POST requests to get the next position after an angle of movement
* *
* @param request A {@link MovementRequest} containing the start coordinate and angle of the movement. * @param request A {@link MovementRequest} containing the start coordinate and angle of the
* movement.
* @return A {@link LngLat} representing the destination * @return A {@link LngLat} representing the destination
*/ */
@PostMapping("/nextPosition") @PostMapping("/nextPosition")

View file

@ -7,17 +7,17 @@ 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;
import java.util.List;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.util.List;
/** /**
* Main Rest Controller for the ILP Coursework 2 application. * Main Rest Controller for the ILP Coursework 2 application.
* <p> *
* This class handles incoming HTTP requests for the API under {@code /api/v1} * <p>This class handles incoming HTTP requests for the API under {@code /api/v1} path (defined in
* path (defined in CW2) * CW2) The business logic is delegated to {@link DroneInfoService}
* The business logic is delegated to {@link DroneInfoService}
*/ */
@RestController @RestController
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
@ -28,37 +28,33 @@ public class DroneController {
private final PathFinderService pathFinderService; private final PathFinderService pathFinderService;
/** /**
* Constructor of the {@code DroneController} with the business logic dependency * Constructor of the {@code DroneController} with the business logic dependency {@code
* {@code DroneInfoService} * DroneInfoService}
* <p> *
* We handle the {@code baseUrl} here. Use a predefined URL if the environment * <p>We handle the {@code baseUrl} here. Use a predefined URL if the environment variable
* variable {@code ILP_ENDPOINT} * {@code ILP_ENDPOINT} is not given.
* is not given.
* *
* @param droneService The service component that contains all business logic * @param droneService The service component that contains all business logic
*/ */
public DroneController( public DroneController(
DroneInfoService droneService, DroneInfoService droneService,
DroneAttrComparatorService droneAttrComparatorService, DroneAttrComparatorService droneAttrComparatorService,
PathFinderService pathFinderService PathFinderService pathFinderService) {
) {
this.droneInfoService = droneService; this.droneInfoService = droneService;
this.droneAttrComparatorService = droneAttrComparatorService; this.droneAttrComparatorService = droneAttrComparatorService;
this.pathFinderService = pathFinderService; this.pathFinderService = pathFinderService;
} }
/** /**
* Handles GET requests to retrieve an array of drones (identified by id) that * Handles GET requests to retrieve an array of drones (identified by id) that has the
* has the capability of cooling * capability of cooling
* *
* @param state The path variable that indicates the return should have or not * @param state The path variable that indicates the return should have or not have the
* have the capability * capability
* @return An array of drone id with cooling capability. * @return An array of drone id with cooling capability.
*/ */
@GetMapping("/dronesWithCooling/{state}") @GetMapping("/dronesWithCooling/{state}")
public List<String> getDronesWithCoolingCapability( public List<String> getDronesWithCoolingCapability(@PathVariable boolean state) {
@PathVariable boolean state
) {
return droneInfoService.dronesWithCooling(state); return droneInfoService.dronesWithCooling(state);
} }
@ -66,8 +62,8 @@ public class DroneController {
* Handles GET requests to retrieve the drone detail identified by id * Handles GET requests to retrieve the drone detail identified by id
* *
* @param id The id of the drone to be queried. * @param id The id of the drone to be queried.
* @return 200 with {@link Drone}-style json if success, 404 if {@code id} * @return 200 with {@link Drone}-style json if success, 404 if {@code id} not found, 400
* not found, 400 otherwise * otherwise
*/ */
@GetMapping("/droneDetails/{id}") @GetMapping("/droneDetails/{id}")
public ResponseEntity<Drone> getDroneDetail(@PathVariable String id) { public ResponseEntity<Drone> getDroneDetail(@PathVariable String id) {
@ -80,8 +76,8 @@ public class DroneController {
} }
/** /**
* Handles GET requests to retrieve an array of drone ids that * Handles GET requests to retrieve an array of drone ids that {@code capability.attrName =
* {@code capability.attrName = attrVal} * attrVal}
* *
* @param attrName The name of the attribute to be queried * @param attrName The name of the attribute to be queried
* @param attrVal The value of the attribute to be queried * @param attrVal The value of the attribute to be queried
@ -89,42 +85,27 @@ public class DroneController {
*/ */
@GetMapping("/queryAsPath/{attrName}/{attrVal}") @GetMapping("/queryAsPath/{attrName}/{attrVal}")
public List<String> getIdByAttrMap( public List<String> getIdByAttrMap(
@PathVariable String attrName, @PathVariable String attrName, @PathVariable String attrVal) {
@PathVariable String attrVal return droneAttrComparatorService.dronesWithAttribute(attrName, attrVal);
) {
return droneAttrComparatorService.dronesWithAttribute(
attrName,
attrVal
);
} }
@PostMapping("/query") @PostMapping("/query")
public List<String> getIdByAttrMapPost( public List<String> getIdByAttrMapPost(@RequestBody AttrQueryRequest[] attrComparators) {
@RequestBody AttrQueryRequest[] attrComparators return droneAttrComparatorService.dronesSatisfyingAttributes(attrComparators);
) {
return droneAttrComparatorService.dronesSatisfyingAttributes(
attrComparators
);
} }
@PostMapping("/queryAvailableDrones") @PostMapping("/queryAvailableDrones")
public List<String> queryAvailableDrones( public List<String> queryAvailableDrones(@RequestBody MedDispatchRecRequest[] records) {
@RequestBody MedDispatchRecRequest[] records
) {
return droneInfoService.dronesMatchesRequirements(records); return droneInfoService.dronesMatchesRequirements(records);
} }
@PostMapping("/calcDeliveryPath") @PostMapping("/calcDeliveryPath")
public DeliveryPathResponse calculateDeliveryPath( public DeliveryPathResponse calculateDeliveryPath(@RequestBody MedDispatchRecRequest[] record) {
@RequestBody MedDispatchRecRequest[] record
) {
return pathFinderService.calculateDeliveryPath(record); return pathFinderService.calculateDeliveryPath(record);
} }
@PostMapping("/calcDeliveryPathAsGeoJson") @PostMapping("/calcDeliveryPathAsGeoJson")
public String calculateDeliveryPathAsGeoJson( public String calculateDeliveryPathAsGeoJson(@RequestBody MedDispatchRecRequest[] record) {
@RequestBody MedDispatchRecRequest[] record
) {
return pathFinderService.calculateDeliveryPathAsGeoJson(record); return pathFinderService.calculateDeliveryPathAsGeoJson(record);
} }
} }

View file

@ -1,14 +1,13 @@
package io.github.js0ny.ilp_coursework.controller; package io.github.js0ny.ilp_coursework.controller;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import io.github.js0ny.ilp_coursework.service.DroneInfoService; import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
public class GeoJsonDataController { public class GeoJsonDataController {

View file

@ -6,9 +6,7 @@ import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
* Represents a range of altitude values (that is a fly-zone in {@link RestrictedArea}). * Represents a range of altitude values (that is a fly-zone in {@link RestrictedArea}).
* *
* @param lower The lower bound of the altitude range. * @param lower The lower bound of the altitude range.
* @param upper The upper bound of the altitude range. If {@code upper = -1}, then the region * @param upper The upper bound of the altitude range. If {@code upper = -1}, then the region is not
* is not a fly zone. * a fly zone.
*
*/ */
public record AltitudeRange(double lower, double upper) { public record AltitudeRange(double lower, double upper) {}
}

View file

@ -11,8 +11,7 @@ public record Angle(double degrees) {
public Angle { public Angle {
if (degrees < 0 || degrees >= 360) { if (degrees < 0 || degrees >= 360) {
throw new IllegalArgumentException( throw new IllegalArgumentException("Angle must be in range [0, 360). Got: " + degrees);
"Angle must be in range [0, 360). Got: " + degrees);
} }
// Should be a multiple of 22.5 (one of the 16 major directions) // Should be a multiple of 22.5 (one of the 16 major directions)
@ -22,18 +21,16 @@ public record Angle(double degrees) {
// 1.0e-15 // 1.0e-15
// So we need to check if the remainder is small enough, or close enough to STEP // So we need to check if the remainder is small enough, or close enough to STEP
// (handling negative errors) // (handling negative errors)
if (Math.abs(remainder) > EPSILON && if (Math.abs(remainder) > EPSILON && Math.abs(remainder - STEP) > EPSILON) {
Math.abs(remainder - STEP) > EPSILON) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"Angle must be a multiple of 22.5 (one of the 16 major directions). Got: " + "Angle must be a multiple of 22.5 (one of the 16 major directions). Got: "
degrees); + degrees);
} }
} }
public static Angle fromIndex(int index) { public static Angle fromIndex(int index) {
if (index < 0 || index > 15) { if (index < 0 || index > 15) {
throw new IllegalArgumentException( throw new IllegalArgumentException("Direction index must be between 0 and 15");
"Direction index must be between 0 and 15");
} }
return new Angle(index * STEP); return new Angle(index * STEP);
} }
@ -64,5 +61,4 @@ public record Angle(double degrees) {
public double toRadians() { public double toRadians() {
return Math.toRadians(degrees); return Math.toRadians(degrees);
} }
} }

View file

@ -3,9 +3,7 @@ package io.github.js0ny.ilp_coursework.data.common;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.time.LocalTime; import java.time.LocalTime;
public record DroneAvailability( public record DroneAvailability(String id, TimeWindow[] availability) {
String id,
TimeWindow[] availability) {
public boolean checkAvailability(DayOfWeek day, LocalTime time) { public boolean checkAvailability(DayOfWeek day, LocalTime time) {

View file

@ -7,6 +7,4 @@ public record DroneCapability(
int maxMoves, int maxMoves,
float costPerMove, float costPerMove,
float costInitial, float costInitial,
float costFinal) { float costFinal) {}
}

View file

@ -1,8 +1,8 @@
package io.github.js0ny.ilp_coursework.data.common; package io.github.js0ny.ilp_coursework.data.common;
/** /**
* Represents the data transfer object for a point or coordinate * Represents the data transfer object for a point or coordinate that defines by a longitude and
* that defines by a longitude and latitude * latitude
* *
* @param lng longitude of the coordinate/point * @param lng longitude of the coordinate/point
* @param lat latitude of the coordinate/point * @param lat latitude of the coordinate/point
@ -13,14 +13,12 @@ public record LngLat(double lng, double lat) {
public LngLat { public LngLat {
if (lat < -90 || lat > 90) { if (lat < -90 || lat > 90) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"Latitude must be between -90 and +90 degrees. Got: " + lat "Latitude must be between -90 and +90 degrees. Got: " + lat);
);
} }
if (lng < -180 || lng > 180) { if (lng < -180 || lng > 180) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"Longitude must be between -180 and +180 degrees. Got: " + lng "Longitude must be between -180 and +180 degrees. Got: " + lng);
);
} }
} }
@ -32,9 +30,6 @@ public record LngLat(double lng, double lat) {
if (other == null) { if (other == null) {
return false; return false;
} }
return ( return (Math.abs(lng - other.lng()) < EPSILON && Math.abs(lat - other.lat()) < EPSILON);
Math.abs(lng - other.lng()) < EPSILON &&
Math.abs(lat - other.lat()) < EPSILON
);
} }
} }

View file

@ -1,12 +1,11 @@
package io.github.js0ny.ilp_coursework.data.common; package io.github.js0ny.ilp_coursework.data.common;
/** /**
* Represents the data transfer object for a point or coordinate * Represents the data transfer object for a point or coordinate that defines by a longitude and
* that defines by a longitude and latitude * latitude
* *
* @param lng longitude of the coordinate/point * @param lng longitude of the coordinate/point
* @param lat latitude of the coordinate/point * @param lat latitude of the coordinate/point
* @param alt altitude of the coordinate/point * @param alt altitude of the coordinate/point
*/ */
public record LngLatAlt(double lng, double lat, double alt) { public record LngLatAlt(double lng, double lat, double alt) {}
}

View file

@ -1,47 +1,42 @@
package io.github.js0ny.ilp_coursework.data.common; package io.github.js0ny.ilp_coursework.data.common;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest; import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import java.util.List; import java.util.List;
import java.util.Objects;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map; import java.util.Map;
import java.util.Objects;
/** /**
* Represents the data transfer object for a region definition * Represents the data transfer object for a region definition
* <p> *
* This record encapsulates the data for calculating if a coordinate is inside * <p>This record encapsulates the data for calculating if a coordinate is inside the region
* the region *
* <p> * <p>A built-in method {@code isClosedTo} is defined to check this DTO is valid or not in the mean
* A built-in method {@code isClosedTo} is defined to check this DTO is valid or * of closing polygon
* not in the mean of closing polygon
* *
* @param name The human-readable name for the region * @param name The human-readable name for the region
* @param vertices list of coordinates that forms a polygon as a region. * @param vertices list of coordinates that forms a polygon as a region.
* <p> * <p>In order to define a valid region, the last element of the list should be the same as the
* In order to define a valid region, the last element of the * first, or known as closed
* list should be the same as the first, or
* known as closed
* @see RegionCheckRequest * @see RegionCheckRequest
* @see io.github.js0ny.ilp_coursework.service.GpsCalculationService#checkIsInRegion(LngLat, * @see io.github.js0ny.ilp_coursework.service.GpsCalculationService#checkIsInRegion(LngLat, Region)
* Region)
*/ */
public record Region(String name, List<LngLat> vertices) { public record Region(String name, List<LngLat> vertices) {
/** /**
* Magic number 4: For a polygon, 3 edges is required. * Magic number 4: For a polygon, 3 edges is required.
* <p> *
* In this dto, edges + 1 vertices is required. * <p>In this dto, edges + 1 vertices is required.
*/ */
private static final int MINIMUM_VERTICES = 4; private static final int MINIMUM_VERTICES = 4;
/** /**
* Method to check if the region has a valid polygon by checking if the * Method to check if the region has a valid polygon by checking if the {@code vertices} forms a
* {@code vertices} forms a closed polygon * closed polygon
* *
* @return {@code true} if the {@code vertices} are able to form a polygon and * @return {@code true} if the {@code vertices} are able to form a polygon and form a closed
* form a closed polygon * polygon
*/ */
public boolean isClosed() { public boolean isClosed() {
if (vertices == null || vertices.size() < MINIMUM_VERTICES) { if (vertices == null || vertices.size() < MINIMUM_VERTICES) {
@ -56,13 +51,10 @@ public record Region(String name, List<LngLat> vertices) {
try { try {
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
List<List<Double>> ring = vertices.stream() List<List<Double>> ring =
.map(v -> List.of(v.lng(), v.lat())) vertices.stream().map(v -> List.of(v.lng(), v.lat())).toList();
.toList();
return Map.of( return Map.of("type", "Polygon", "coordinates", List.of(ring));
"type", "Polygon",
"coordinates", List.of(ring));
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("Failed to generate GeoJSON", e); throw new RuntimeException("Failed to generate GeoJSON", e);

View file

@ -3,8 +3,4 @@ package io.github.js0ny.ilp_coursework.data.common;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.time.LocalTime; import java.time.LocalTime;
public record TimeWindow( public record TimeWindow(DayOfWeek dayOfWeek, LocalTime from, LocalTime until) {}
DayOfWeek dayOfWeek,
LocalTime from,
LocalTime until) {
}

View file

@ -2,13 +2,8 @@ package io.github.js0ny.ilp_coursework.data.external;
import io.github.js0ny.ilp_coursework.data.common.DroneCapability; import io.github.js0ny.ilp_coursework.data.common.DroneCapability;
/** /** Represents the data transfer object for a drone, gained from the endpoints */
* Represents the data transfer object for a drone, gained from the endpoints public record Drone(String name, String id, DroneCapability capability) {
*/
public record Drone(
String name,
String id,
DroneCapability capability) {
public int parseId() { public int parseId() {
try { try {

View file

@ -4,15 +4,11 @@ import io.github.js0ny.ilp_coursework.data.common.AltitudeRange;
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.LngLatAlt;
import io.github.js0ny.ilp_coursework.data.common.Region; import io.github.js0ny.ilp_coursework.data.common.Region;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
public record RestrictedArea( public record RestrictedArea(String name, int id, AltitudeRange limits, LngLatAlt[] vertices) {
String name,
int id,
AltitudeRange limits,
LngLatAlt[] vertices
) {
public Region toRegion() { public Region toRegion() {
List<LngLat> vertices2D = new ArrayList<>(); List<LngLat> vertices2D = new ArrayList<>();
for (var vertex : vertices) { for (var vertex : vertices) {

View file

@ -1,11 +1,10 @@
package io.github.js0ny.ilp_coursework.data.external; package io.github.js0ny.ilp_coursework.data.external;
import io.github.js0ny.ilp_coursework.data.common.DroneAvailability; import io.github.js0ny.ilp_coursework.data.common.DroneAvailability;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
public record ServicePointDrones( public record ServicePointDrones(int servicePointId, DroneAvailability[] drones) {
int servicePointId,
DroneAvailability[] drones) {
@Nullable @Nullable
public DroneAvailability locateDroneById(String droneId) { public DroneAvailability locateDroneById(String droneId) {

View file

@ -3,5 +3,4 @@ package io.github.js0ny.ilp_coursework.data.request;
// TODO: Convert operator to Enum // TODO: Convert operator to Enum
// import io.github.js0ny.ilp_coursework.util.AttrOperator; // import io.github.js0ny.ilp_coursework.util.AttrOperator;
public record AttrQueryRequest(String attribute, String operator, String value) { public record AttrQueryRequest(String attribute, String operator, String value) {}
}

View file

@ -4,12 +4,11 @@ import io.github.js0ny.ilp_coursework.data.common.LngLat;
/** /**
* Represents the data transfer object for a distance operation request. * Represents the data transfer object for a distance operation request.
* <p> *
* This record encapsulates the data for several endpoints that involves two {@code LngLatDto} * <p>This record encapsulates the data for several endpoints that involves two {@code LngLatDto}
* and serves as the data contract for those API operation * and serves as the data contract for those API operation
* *
* @param position1 Nested object of {@link LngLat} * @param position1 Nested object of {@link LngLat}
* @param position2 Nested object of {@link LngLat} * @param position2 Nested object of {@link LngLat}
*/ */
public record DistanceRequest(LngLat position1, LngLat position2) { public record DistanceRequest(LngLat position1, LngLat position2) {}
}

View file

@ -1,6 +1,7 @@
package io.github.js0ny.ilp_coursework.data.request; package io.github.js0ny.ilp_coursework.data.request;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.github.js0ny.ilp_coursework.data.common.LngLat; import io.github.js0ny.ilp_coursework.data.common.LngLat;
import java.time.LocalDate; import java.time.LocalDate;
@ -8,17 +9,7 @@ import java.time.LocalTime;
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record MedDispatchRecRequest( public record MedDispatchRecRequest(
int id, int id, LocalDate date, LocalTime time, MedRequirement requirements, LngLat delivery) {
LocalDate date,
LocalTime time,
MedRequirement requirements,
LngLat delivery) {
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record MedRequirement( public record MedRequirement(float capacity, boolean cooling, boolean heating, float maxCost) {}
float capacity,
boolean cooling,
boolean heating,
float maxCost
) {
}
} }

View file

@ -4,14 +4,13 @@ import io.github.js0ny.ilp_coursework.data.common.LngLat;
/** /**
* Represents the data transfer object for a movement action request. * Represents the data transfer object for a movement action request.
* <p> *
* This record encapsulates the data for endpoint /api/v1/nextPosition and serves as the data contract for * <p>This record encapsulates the data for endpoint /api/v1/nextPosition and serves as the data
* this API operation * contract for this API operation
* *
* @param start The starting coordinate of the movement * @param start The starting coordinate of the movement
* @param angle The angle to movement in degree. This corresponds to compass directions. * @param angle The angle to movement in degree. This corresponds to compass directions. For
* For example: 0 for East, 90 for North * example: 0 for East, 90 for North
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getNextPosition(MovementRequest) * @see io.github.js0ny.ilp_coursework.controller.ApiController#getNextPosition(MovementRequest)
*/ */
public record MovementRequest(LngLat start, double angle) { public record MovementRequest(LngLat start, double angle) {}
}

View file

@ -5,15 +5,14 @@ import io.github.js0ny.ilp_coursework.data.common.Region;
/** /**
* Represents the data transfer object for a region check request. * Represents the data transfer object for a region check request.
* <p> *
* This record encapsulates the data for endpoint /api/v1/isInRegion and serves as the data contract for * <p>This record encapsulates the data for endpoint /api/v1/isInRegion and serves as the data
* this API operation * contract for this API operation
*
* <p> * <p>
* *
* @param position The coordinate to be checked * @param position The coordinate to be checked
* @param region The region for the check. * @param region The region for the check. This is a nested object represented by {@link Region}
* This is a nested object represented by {@link Region}
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getIsInRegion(RegionCheckRequest) * @see io.github.js0ny.ilp_coursework.controller.ApiController#getIsInRegion(RegionCheckRequest)
*/ */
public record RegionCheckRequest(LngLat position, Region region) { public record RegionCheckRequest(LngLat position, Region region) {}
}

View file

@ -1,13 +1,10 @@
package io.github.js0ny.ilp_coursework.data.response; package io.github.js0ny.ilp_coursework.data.response;
import io.github.js0ny.ilp_coursework.data.common.LngLat; import io.github.js0ny.ilp_coursework.data.common.LngLat;
import java.util.List; import java.util.List;
public record DeliveryPathResponse( public record DeliveryPathResponse(float totalCost, int totalMoves, DronePath[] dronePaths) {
float totalCost,
int totalMoves,
DronePath[] dronePaths
) {
public record DronePath(int droneId, List<Delivery> deliveries) { public record DronePath(int droneId, List<Delivery> deliveries) {
public record Delivery(int deliveryId, List<LngLat> flightPath) {} public record Delivery(int deliveryId, List<LngLat> flightPath) {}
} }

View file

@ -1,8 +1,5 @@
package io.github.js0ny.ilp_coursework.exception; package io.github.js0ny.ilp_coursework.exception;
import java.util.Map;
import java.util.Optional;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -11,15 +8,17 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
/** import java.util.Map;
* Class that handles exception or failed request. Map all error requests to 400. import java.util.Optional;
*/
/** Class that handles exception or failed request. Map all error requests to 400. */
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
/// Use a logger to save logs instead of passing them to user /// Use a logger to save logs instead of passing them to user
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final Map<String, String> badRequestMap = Map.of("status", "400", "error", "Bad Request"); private final Map<String, String> badRequestMap =
Map.of("status", "400", "error", "Bad Request");
@ExceptionHandler(HttpMessageNotReadableException.class) @ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseStatus(HttpStatus.BAD_REQUEST)
@ -31,8 +30,8 @@ public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class) @ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleIllegalArgument(IllegalArgumentException ex) { public Map<String, String> handleIllegalArgument(IllegalArgumentException ex) {
String errorMessage = Optional.ofNullable(ex.getMessage()) String errorMessage =
.orElse("Invalid argument provided."); Optional.ofNullable(ex.getMessage()).orElse("Invalid argument provided.");
log.warn("Illegal argument in request: {}", errorMessage); log.warn("Illegal argument in request: {}", errorMessage);
return badRequestMap; return badRequestMap;
} }

View file

@ -4,9 +4,14 @@ import static io.github.js0ny.ilp_coursework.util.AttrComparator.isValueMatched;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
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.util.AttrOperator; import io.github.js0ny.ilp_coursework.util.AttrOperator;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.net.URI; import java.net.URI;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
@ -14,8 +19,6 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service @Service
public class DroneAttrComparatorService { public class DroneAttrComparatorService {
@ -24,14 +27,11 @@ public class DroneAttrComparatorService {
private final String dronesEndpoint = "drones"; private final String dronesEndpoint = "drones";
private final RestTemplate restTemplate = new RestTemplate(); private final RestTemplate restTemplate = new RestTemplate();
/** /** Constructor, handles the base url here. */
* Constructor, handles the base url here.
*/
public DroneAttrComparatorService() { public DroneAttrComparatorService() {
String baseUrl = System.getenv("ILP_ENDPOINT"); String baseUrl = System.getenv("ILP_ENDPOINT");
if (baseUrl == null || baseUrl.isBlank()) { if (baseUrl == null || baseUrl.isBlank()) {
this.baseUrl = this.baseUrl = "https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
"https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
} else { } else {
// Defensive: Add '/' to the end of the URL // Defensive: Add '/' to the end of the URL
if (!baseUrl.endsWith("/")) { if (!baseUrl.endsWith("/")) {
@ -43,8 +43,8 @@ public class DroneAttrComparatorService {
/** /**
* Return an array of ids of drones with a given attribute name and value. * Return an array of ids of drones with a given attribute name and value.
* <p> *
* Associated service method with {@code /queryAsPath/{attrName}/{attrVal}} * <p>Associated service method with {@code /queryAsPath/{attrName}/{attrVal}}
* *
* @param attrName the attribute name to filter on * @param attrName the attribute name to filter on
* @param attrVal the attribute value to filter on * @param attrVal the attribute value to filter on
@ -58,26 +58,19 @@ public class DroneAttrComparatorService {
} }
/** /**
* Return an array of ids of drones which matches all given complex comparing * Return an array of ids of drones which matches all given complex comparing rules
* rules
* *
* @param attrComparators The filter rule with Name, Value and Operator * @param attrComparators The filter rule with Name, Value and Operator
* @return array of drone ids that matches all rules * @return array of drone ids that matches all rules
*/ */
public List<String> dronesSatisfyingAttributes( public List<String> dronesSatisfyingAttributes(AttrQueryRequest[] attrComparators) {
AttrQueryRequest[] attrComparators
) {
Set<String> matchingDroneIds = null; Set<String> matchingDroneIds = null;
for (var comparator : attrComparators) { for (var comparator : attrComparators) {
String attribute = comparator.attribute(); String attribute = comparator.attribute();
String operator = comparator.operator(); String operator = comparator.operator();
String value = comparator.value(); String value = comparator.value();
AttrOperator op = AttrOperator.fromString(operator); AttrOperator op = AttrOperator.fromString(operator);
List<String> ids = dronesWithAttributeCompared( List<String> ids = dronesWithAttributeCompared(attribute, value, op);
attribute,
value,
op
);
if (matchingDroneIds == null) { if (matchingDroneIds == null) {
matchingDroneIds = new HashSet<>(ids); matchingDroneIds = new HashSet<>(ids);
} else { } else {
@ -92,25 +85,20 @@ public class DroneAttrComparatorService {
/** /**
* Helper that wraps the dynamic querying with different comparison operators * Helper that wraps the dynamic querying with different comparison operators
* <p> *
* This method act as a concatenation of * <p>This method act as a concatenation of {@link
* {@link io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode, String, * io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode, String,
* AttrOperator)} * AttrOperator)}
* *
* @param attrName the attribute name to filter on * @param attrName the attribute name to filter on
* @param attrVal the attribute value to filter on * @param attrVal the attribute value to filter on
* @param op the comparison operator * @param op the comparison operator
* @return array of drone ids matching the attribute name and value (filtered by * @return array of drone ids matching the attribute name and value (filtered by {@code op})
* {@code op}) * @see io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode, String,
* @see io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode,
* String,
* AttrOperator) * AttrOperator)
*/ */
private List<String> dronesWithAttributeCompared( private List<String> dronesWithAttributeCompared(
String attrName, String attrName, String attrVal, AttrOperator op) {
String attrVal,
AttrOperator op
) {
URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint); URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
// This is required to make sure the response is valid // This is required to make sure the response is valid
Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class); Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class);
@ -124,7 +112,8 @@ public class DroneAttrComparatorService {
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
return Arrays.stream(drones) return Arrays.stream(drones)
.filter(drone -> { .filter(
drone -> {
JsonNode node = mapper.valueToTree(drone); JsonNode node = mapper.valueToTree(drone);
JsonNode attrNode = node.findValue(attrName); JsonNode attrNode = node.findValue(attrName);
if (attrNode != null) { if (attrNode != null) {

View file

@ -2,6 +2,7 @@ package io.github.js0ny.ilp_coursework.service;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; 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.common.Region;
import io.github.js0ny.ilp_coursework.data.external.Drone; import io.github.js0ny.ilp_coursework.data.external.Drone;
@ -9,30 +10,29 @@ 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.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;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.net.URI; import java.net.URI;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime; import java.time.LocalTime;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service @Service
public class DroneInfoService { public class DroneInfoService {
private final String baseUrl; private final String baseUrl;
private final String dronesForServicePointsEndpoint = private final String dronesForServicePointsEndpoint = "drones-for-service-points";
"drones-for-service-points";
public static final String servicePointsEndpoint = "service-points"; public static final String servicePointsEndpoint = "service-points";
public static final String restrictedAreasEndpoint = "restricted-areas"; public static final String restrictedAreasEndpoint = "restricted-areas";
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
/** /** Constructor, handles the base url here. */
* Constructor, handles the base url here.
*/
public DroneInfoService() { public DroneInfoService() {
this(new RestTemplate()); this(new RestTemplate());
} }
@ -41,8 +41,7 @@ public class DroneInfoService {
this.restTemplate = restTemplate; this.restTemplate = restTemplate;
String baseUrl = System.getenv("ILP_ENDPOINT"); String baseUrl = System.getenv("ILP_ENDPOINT");
if (baseUrl == null || baseUrl.isBlank()) { if (baseUrl == null || baseUrl.isBlank()) {
this.baseUrl = this.baseUrl = "https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
"https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
} else { } else {
// Defensive: Add '/' to the end of the URL // Defensive: Add '/' to the end of the URL
if (!baseUrl.endsWith("/")) { if (!baseUrl.endsWith("/")) {
@ -54,13 +53,14 @@ 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> *
* Associated service method with {@code /dronesWithCooling/{state}} * <p>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 io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean) * @see
* io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean)
*/ */
public List<String> dronesWithCooling(boolean state) { public List<String> dronesWithCooling(boolean state) {
// URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint); // URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
@ -71,8 +71,7 @@ public class DroneInfoService {
return new ArrayList<>(); return new ArrayList<>();
} }
return drones return drones.stream()
.stream()
.filter(drone -> drone.capability().cooling() == state) .filter(drone -> drone.capability().cooling() == state)
.map(Drone::id) .map(Drone::id)
.collect(Collectors.toList()); .collect(Collectors.toList());
@ -80,16 +79,14 @@ 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> *
* Associated service method with {@code /droneDetails/{id}} * <p>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 * lead to a 404
* found
* this should lead to a 404
* @see io.github.js0ny.ilp_coursework.controller.DroneController#getDroneDetail(String) * @see io.github.js0ny.ilp_coursework.controller.DroneController#getDroneDetail(String)
*/ */
public Drone droneDetail(String id) { public Drone droneDetail(String id) {
@ -102,16 +99,14 @@ public class DroneInfoService {
} }
// This will result in 404 // This will result in 404
throw new IllegalArgumentException( throw new IllegalArgumentException("drone with that ID cannot be found");
"drone with that ID cannot be found"
);
} }
/** /**
* 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> *
* Associated service method with * <p>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
@ -121,8 +116,7 @@ public class DroneInfoService {
List<Drone> drones = fetchAllDrones(); List<Drone> drones = fetchAllDrones();
if (rec == null || rec.length == 0) { if (rec == null || rec.length == 0) {
return drones return drones.stream()
.stream()
.filter(Objects::nonNull) .filter(Objects::nonNull)
.map(Drone::id) .map(Drone::id)
.collect(Collectors.toList()); .collect(Collectors.toList());
@ -131,14 +125,13 @@ public class DroneInfoService {
/* /*
* Traverse and filter drones, pass every record's requirement to helper * Traverse and filter drones, pass every record's requirement to helper
*/ */
return drones return drones.stream()
.stream()
.filter(d -> d != null && d.capability() != null) .filter(d -> d != null && d.capability() != null)
.filter(d -> .filter(
d ->
Arrays.stream(rec) 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)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@ -149,23 +142,17 @@ 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 * (capacity and id cannot be null in {@code MedDispathRecDto})
* in {@code MedDispathRecDto})
*/ */
public boolean droneMatchesRequirement( public boolean droneMatchesRequirement(Drone drone, MedDispatchRecRequest record) {
Drone drone,
MedDispatchRecRequest record
) {
var requirements = record.requirements(); var requirements = record.requirements();
if (requirements == null) { if (requirements == null) {
throw new IllegalArgumentException("requirements cannot be null"); throw new IllegalArgumentException("requirements cannot be null");
} }
var capability = drone.capability(); var capability = drone.capability();
if (capability == null) { if (capability == null) {
throw new IllegalArgumentException( throw new IllegalArgumentException("drone capability cannot be null");
"drone capability cannot be null"
);
} }
float requiredCapacity = requirements.capacity(); float requiredCapacity = requirements.capacity();
@ -187,11 +174,7 @@ public class DroneInfoService {
// Conditions: All requirements matched + availability matched, use helper // Conditions: All requirements matched + availability matched, use helper
// For minimal privilege, only pass drone id to check availability // For minimal privilege, only pass drone id to check availability
return ( return (matchesCooling && matchesHeating && checkAvailability(drone.id(), record)); // &&
matchesCooling &&
matchesHeating &&
checkAvailability(drone.id(), record)
); // &&
// checkCost(drone, record) // checkCost is more expensive than // checkCost(drone, record) // checkCost is more expensive than
// checkAvailability // checkAvailability
} }
@ -200,21 +183,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( private boolean checkAvailability(String droneId, MedDispatchRecRequest record) {
String droneId, URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
MedDispatchRecRequest record ServicePointDrones[] servicePoints =
) { restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
URI droneUrl = URI.create(baseUrl).resolve(
dronesForServicePointsEndpoint
);
ServicePointDrones[] servicePoints = restTemplate.getForObject(
droneUrl,
ServicePointDrones[].class
);
LocalDate requiredDate = record.date(); LocalDate requiredDate = record.date();
DayOfWeek requiredDay = requiredDate.getDayOfWeek(); DayOfWeek requiredDay = requiredDate.getDayOfWeek();
@ -232,13 +207,9 @@ public class DroneInfoService {
} }
private LngLat queryServicePointLocationByDroneId(String droneId) { private LngLat queryServicePointLocationByDroneId(String droneId) {
URI droneUrl = URI.create(baseUrl).resolve( URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
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) {
@ -253,14 +224,10 @@ public class DroneInfoService {
@Nullable @Nullable
private LngLat queryServicePointLocation(int id) { private LngLat queryServicePointLocation(int id) {
URI servicePointUrl = URI.create(baseUrl).resolve( URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
servicePointsEndpoint
);
ServicePoint[] servicePoints = restTemplate.getForObject( ServicePoint[] servicePoints =
servicePointUrl, restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
ServicePoint[].class
);
assert servicePoints != null; assert servicePoints != null;
for (var sp : servicePoints) { for (var sp : servicePoints) {
@ -282,49 +249,32 @@ public class DroneInfoService {
} }
public List<RestrictedArea> fetchRestrictedAreas() { public List<RestrictedArea> fetchRestrictedAreas() {
URI restrictedUrl = URI.create(baseUrl).resolve( URI restrictedUrl = URI.create(baseUrl).resolve(restrictedAreasEndpoint);
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<String> fetchRestrictedAreasInGeoJson() public List<String> fetchRestrictedAreasInGeoJson() throws JsonProcessingException {
throws JsonProcessingException {
var mapper = new ObjectMapper(); var mapper = new ObjectMapper();
var ras = fetchRestrictedAreas(); var ras = fetchRestrictedAreas();
var geoJson = ras var geoJson = ras.stream().map(RestrictedArea::toRegion).map(Region::toGeoJson).toList();
.stream()
.map(RestrictedArea::toRegion)
.map(Region::toGeoJson)
.toList();
return Collections.singletonList(mapper.writeValueAsString(geoJson)); return Collections.singletonList(mapper.writeValueAsString(geoJson));
} }
public List<ServicePoint> fetchServicePoints() { public List<ServicePoint> fetchServicePoints() {
URI servicePointUrl = URI.create(baseUrl).resolve( URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
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( URI servicePointDronesUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
dronesForServicePointsEndpoint ServicePointDrones[] servicePointDrones =
); restTemplate.getForObject(servicePointDronesUrl, ServicePointDrones[].class);
ServicePointDrones[] servicePointDrones = restTemplate.getForObject(
servicePointDronesUrl,
ServicePointDrones[].class
);
assert servicePointDrones != null; assert servicePointDrones != null;
return Arrays.asList(servicePointDrones); return Arrays.asList(servicePointDrones);
} }

View file

@ -6,9 +6,11 @@ import io.github.js0ny.ilp_coursework.data.common.Region;
import io.github.js0ny.ilp_coursework.data.request.DistanceRequest; import io.github.js0ny.ilp_coursework.data.request.DistanceRequest;
import io.github.js0ny.ilp_coursework.data.request.MovementRequest; 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 java.util.List;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List;
/** /**
* Class that handles calculations about Coordinates * Class that handles calculations about Coordinates
* *
@ -24,6 +26,7 @@ public class GpsCalculationService {
* @see #nextPosition(LngLat, double) * @see #nextPosition(LngLat, double)
*/ */
private static final double STEP = 0.00015; private static final double STEP = 0.00015;
/** /**
* Given threshold to judge if two points are close to each other * Given threshold to judge if two points are close to each other
* *
@ -32,14 +35,12 @@ public class GpsCalculationService {
private static final double CLOSE_THRESHOLD = 0.00015; private static final double CLOSE_THRESHOLD = 0.00015;
/** /**
* Calculate the Euclidean distance between {@code position1} and * Calculate the Euclidean distance between {@code position1} and {@code position2}, which are
* {@code position2}, which are coordinates * coordinates defined as {@link LngLat}
* defined as {@link LngLat}
* *
* @param position1 The coordinate of the first position * @param position1 The coordinate of the first position
* @param position2 The coordinate of the second position * @param position2 The coordinate of the second position
* @return The Euclidean distance between {@code position1} and * @return The Euclidean distance between {@code position1} and {@code position2}
* {@code position2}
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getDistance(DistanceRequest) * @see io.github.js0ny.ilp_coursework.controller.ApiController#getDistance(DistanceRequest)
*/ */
public double calculateDistance(LngLat position1, LngLat position2) { public double calculateDistance(LngLat position1, LngLat position2) {
@ -55,17 +56,14 @@ public class GpsCalculationService {
} }
/** /**
* Check if {@code position1} and * Check if {@code position1} and {@code position2} are close to each other, the threshold is <
* {@code position2} are close to each other, the threshold is < 0.00015 * 0.00015
* *
* <p> * <p>Note that = 0.00015 will be counted as not close to and will return {@code false}
* Note that = 0.00015 will be counted as not close to and will return {@code
* false}
* *
* @param position1 The coordinate of the first position * @param position1 The coordinate of the first position
* @param position2 The coordinate of the second position * @param position2 The coordinate of the second position
* @return {@code true} if {@code position1} and * @return {@code true} if {@code position1} and {@code position2} are close to each other
* {@code position2} are close to each other
* @see #CLOSE_THRESHOLD * @see #CLOSE_THRESHOLD
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getIsCloseTo(DistanceRequest) * @see io.github.js0ny.ilp_coursework.controller.ApiController#getIsCloseTo(DistanceRequest)
*/ */
@ -75,9 +73,8 @@ public class GpsCalculationService {
} }
/** /**
* Returns the next position moved from {@code start} in the direction with * Returns the next position moved from {@code start} in the direction with {@code angle}, with
* {@code angle}, with step size * step size 0.00015
* 0.00015
* *
* @param start The coordinate of the original start point. * @param start The coordinate of the original start point.
* @param angle The direction to be moved in angle. * @param angle The direction to be moved in angle.
@ -93,19 +90,18 @@ public class GpsCalculationService {
} }
/** /**
* Used to check if the given {@code position} * Used to check if the given {@code position} is inside the {@code region}, on edge and vertex
* is inside the {@code region}, on edge and vertex is considered as inside. * is considered as inside.
* *
* @param position The coordinate of the position. * @param position The coordinate of the position.
* @param region A {@link Region} that contains name and a list of * @param region A {@link Region} that contains name and a list of {@code LngLatDto}
* {@code LngLatDto}
* @return {@code true} if {@code position} is inside the {@code region}. * @return {@code true} if {@code position} is inside the {@code region}.
* @throws IllegalArgumentException If {@code region} is not closed * @throws IllegalArgumentException If {@code region} is not closed
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getIsInRegion(RegionCheckRequest) * @see
* io.github.js0ny.ilp_coursework.controller.ApiController#getIsInRegion(RegionCheckRequest)
* @see Region#isClosed() * @see Region#isClosed()
*/ */
public boolean checkIsInRegion(LngLat position, Region region) public boolean checkIsInRegion(LngLat position, Region region) throws IllegalArgumentException {
throws IllegalArgumentException {
if (!region.isClosed()) { if (!region.isClosed()) {
// call method from RegionDto to check if not closed // call method from RegionDto to check if not closed
throw new IllegalArgumentException("Region is not closed."); throw new IllegalArgumentException("Region is not closed.");
@ -114,14 +110,12 @@ public class GpsCalculationService {
} }
/** /**
* Helper function to {@code checkIsInRegion}, use of ray-casting algorithm * Helper function to {@code checkIsInRegion}, use of ray-casting algorithm to check if inside
* to check if inside the polygon * the polygon
* *
* @param point The point to check * @param point The point to check
* @param polygon The region that forms a polygon to check if {@code point} * @param polygon The region that forms a polygon to check if {@code point} sits inside.
* sits inside. * @return If the {@code point} sits inside the {@code polygon} then return {@code true}
* @return If the {@code point} sits inside the {@code polygon} then
* return {@code true}
* @see #isPointOnEdge(LngLat, LngLat, LngLat) * @see #isPointOnEdge(LngLat, LngLat, LngLat)
* @see #checkIsInRegion(LngLat, Region) * @see #checkIsInRegion(LngLat, Region)
*/ */
@ -154,9 +148,7 @@ public class GpsCalculationService {
} }
double xIntersection = double xIntersection =
a.lng() + a.lng() + ((point.lat() - a.lat()) * (b.lng() - a.lng())) / (b.lat() - a.lat());
((point.lat() - a.lat()) * (b.lng() - a.lng())) /
(b.lat() - a.lat());
if (xIntersection > point.lng()) { if (xIntersection > point.lng()) {
++intersections; ++intersections;
@ -170,8 +162,7 @@ public class GpsCalculationService {
/** /**
* Helper function from {@code rayCasting} that used to simply calculation <br> * Helper function from {@code rayCasting} that used to simply calculation <br>
* Used to check if point {@code p} is on the edge formed by * Used to check if point {@code p} is on the edge formed by {@code a} and {@code b}
* {@code a} and {@code b}
* *
* @param p point to be checked on the edge * @param p point to be checked on the edge
* @param a point that forms the edge * @param a point that forms the edge
@ -182,18 +173,16 @@ public class GpsCalculationService {
private boolean isPointOnEdge(LngLat p, LngLat a, LngLat b) { private boolean isPointOnEdge(LngLat p, LngLat a, LngLat b) {
// Cross product: (p - a) × (b - a) // Cross product: (p - a) × (b - a)
double crossProduct = double crossProduct =
(p.lng() - a.lng()) * (b.lat() - a.lat()) - (p.lng() - a.lng()) * (b.lat() - a.lat())
(p.lat() - a.lat()) * (b.lng() - a.lng()); - (p.lat() - a.lat()) * (b.lng() - a.lng());
if (Math.abs(crossProduct) > 1e-9) { if (Math.abs(crossProduct) > 1e-9) {
return false; return false;
} }
boolean isWithinLng = boolean isWithinLng =
p.lng() >= Math.min(a.lng(), b.lng()) && p.lng() >= Math.min(a.lng(), b.lng()) && p.lng() <= Math.max(a.lng(), b.lng());
p.lng() <= Math.max(a.lng(), b.lng());
boolean isWithinLat = boolean isWithinLat =
p.lat() >= Math.min(a.lat(), b.lat()) && p.lat() >= Math.min(a.lat(), b.lat()) && p.lat() <= Math.max(a.lat(), b.lat());
p.lat() <= Math.max(a.lat(), b.lat());
return isWithinLng && isWithinLat; return isWithinLng && isWithinLat;
} }

View file

@ -19,6 +19,9 @@ import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse; import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath; import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath.Delivery; import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath.Delivery;
import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
@ -27,7 +30,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 org.springframework.stereotype.Service;
/** /**
* Class that handles calculations about deliverypath * Class that handles calculations about deliverypath
@ -40,9 +42,8 @@ import org.springframework.stereotype.Service;
public class PathFinderService { public class PathFinderService {
/** /**
* Hard stop on how many pathfinding iterations we attempt for a single * Hard stop on how many pathfinding iterations we attempt for a single segment before bailing,
* segment before bailing, useful for preventing infinite loops caused by * useful for preventing infinite loops caused by precision quirks or unexpected map data.
* precision quirks or unexpected map data.
* *
* @see #computePath(LngLat, LngLat) * @see #computePath(LngLat, LngLat)
*/ */
@ -59,17 +60,14 @@ public class PathFinderService {
private final List<Region> restrictedRegions; private final List<Region> restrictedRegions;
/** /**
* Constructor for PathFinderService. The dependencies are injected by * Constructor for PathFinderService. The dependencies are injected by Spring and the
* Spring and the constructor pre-computes reference maps used throughout the * constructor pre-computes reference maps used throughout the request lifecycle.
* 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, GpsCalculationService gpsCalculationService, DroneInfoService droneInfoService) {
DroneInfoService droneInfoService) {
this.gpsCalculationService = gpsCalculationService; this.gpsCalculationService = gpsCalculationService;
this.droneInfoService = droneInfoService; this.droneInfoService = droneInfoService;
this.objectMapper = new ObjectMapper(); this.objectMapper = new ObjectMapper();
@ -79,11 +77,11 @@ 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( this.droneById = this.drones.stream().collect(Collectors.toMap(Drone::id, drone -> drone));
Collectors.toMap(Drone::id, drone -> drone));
this.droneServicePointMap = new HashMap<>(); this.droneServicePointMap = new HashMap<>();
for (ServicePointDrones assignment : servicePointAssignments) { for (ServicePointDrones assignment : servicePointAssignments) {
@ -94,40 +92,33 @@ public class PathFinderService {
if (availability == null || availability.id() == null) { if (availability == null || availability.id() == null) {
continue; continue;
} }
droneServicePointMap.put( droneServicePointMap.put(availability.id(), assignment.servicePointId());
availability.id(),
assignment.servicePointId());
} }
} }
this.servicePointLocations = servicePoints this.servicePointLocations =
.stream() servicePoints.stream()
.collect( .collect(
Collectors.toMap(ServicePoint::id, sp -> new LngLat(sp.location()))); Collectors.toMap(
ServicePoint::id, sp -> new LngLat(sp.location())));
this.restrictedRegions = restrictedAreas this.restrictedRegions = restrictedAreas.stream().map(RestrictedArea::toRegion).toList();
.stream()
.map(RestrictedArea::toRegion)
.toList();
} }
/** /**
* 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 * compatible drone and per trip to satisfy each drone move limit.
* limit.
* *
* @param records Dispatch records to be fulfilled. * @param records Dispatch records to be fulfilled.
* @return Aggregated path response with cost and move totals. * @return Aggregated path response with cost and move totals.
* @see #calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[]) * @see #calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[])
*/ */
public DeliveryPathResponse calculateDeliveryPath( public DeliveryPathResponse calculateDeliveryPath(MedDispatchRecRequest[] records) {
MedDispatchRecRequest[] records) {
if (records == null || records.length == 0) { if (records == null || records.length == 0) {
return new DeliveryPathResponse(0f, 0, new DronePath[0]); return new DeliveryPathResponse(0f, 0, new DronePath[0]);
} }
Map<String, List<MedDispatchRecRequest>> assigned = assignDeliveries( Map<String, List<MedDispatchRecRequest>> assigned = assignDeliveries(records);
records);
List<DronePath> paths = new ArrayList<>(); List<DronePath> paths = new ArrayList<>();
float totalCost = 0f; float totalCost = 0f;
@ -148,25 +139,20 @@ public class PathFinderService {
continue; continue;
} }
List<MedDispatchRecRequest> sortedDeliveries = entry List<MedDispatchRecRequest> sortedDeliveries =
.getValue() entry.getValue().stream()
.stream()
.sorted( .sorted(
Comparator.comparingDouble(rec -> gpsCalculationService.calculateDistance( Comparator.comparingDouble(
servicePointLocation, rec ->
rec.delivery()))) gpsCalculationService.calculateDistance(
servicePointLocation, rec.delivery())))
.toList(); .toList();
List<List<MedDispatchRecRequest>> trips = splitTrips( List<List<MedDispatchRecRequest>> trips =
sortedDeliveries, splitTrips(sortedDeliveries, drone, servicePointLocation);
drone,
servicePointLocation);
for (List<MedDispatchRecRequest> trip : trips) { for (List<MedDispatchRecRequest> trip : trips) {
TripResult result = buildTrip( TripResult result = buildTrip(drone, servicePointLocation, trip);
drone,
servicePointLocation,
trip);
if (result != null) { if (result != null) {
totalCost += result.cost(); totalCost += result.cost();
totalMoves += result.moves(); totalMoves += result.moves();
@ -175,23 +161,18 @@ public class PathFinderService {
} }
} }
return new DeliveryPathResponse( return new DeliveryPathResponse(totalCost, totalMoves, paths.toArray(new DronePath[0]));
totalCost,
totalMoves,
paths.toArray(new DronePath[0]));
} }
/** /**
* Convenience wrapper around {@link #calculateDeliveryPath} that serializes * Convenience wrapper around {@link #calculateDeliveryPath} that serializes the result into a
* the result into a GeoJSON FeatureCollection suitable for mapping * GeoJSON FeatureCollection suitable for mapping visualization.
* visualization.
* *
* @param records Dispatch records to be fulfilled. * @param records Dispatch records to be fulfilled.
* @return GeoJSON payload representing every delivery flight path. * @return GeoJSON payload representing every delivery flight path.
* @throws IllegalStateException When the payload cannot be serialized. * @throws IllegalStateException When the payload cannot be serialized.
*/ */
public String calculateDeliveryPathAsGeoJson( public String calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[] records) {
MedDispatchRecRequest[] records) {
DeliveryPathResponse response = calculateDeliveryPath(records); DeliveryPathResponse response = calculateDeliveryPath(records);
Map<String, Object> featureCollection = new LinkedHashMap<>(); Map<String, Object> featureCollection = new LinkedHashMap<>();
featureCollection.put("type", "FeatureCollection"); featureCollection.put("type", "FeatureCollection");
@ -232,16 +213,13 @@ public class PathFinderService {
try { try {
return objectMapper.writeValueAsString(featureCollection); return objectMapper.writeValueAsString(featureCollection);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
throw new IllegalStateException( throw new IllegalStateException("Failed to generate GeoJSON payload", e);
"Failed to generate GeoJSON payload",
e);
} }
} }
/** /**
* 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 * {@link #findBestDrone(MedDispatchRecRequest)} exactly once and discarding invalid entries.
* 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.
@ -254,16 +232,14 @@ public class PathFinderService {
continue; continue;
} }
String droneId = findBestDrone(record); String droneId = findBestDrone(record);
assignments assignments.computeIfAbsent(droneId, id -> new ArrayList<>()).add(record);
.computeIfAbsent(droneId, id -> new ArrayList<>())
.add(record);
} }
return assignments; return assignments;
} }
/** /**
* 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.
* @return Identifier of the drone that should fly the mission. * @return Identifier of the drone that should fly the mission.
@ -282,15 +258,14 @@ public class PathFinderService {
if (servicePointId == null) { if (servicePointId == null) {
continue; continue;
} }
LngLat servicePointLocation = servicePointLocations.get( LngLat servicePointLocation = servicePointLocations.get(servicePointId);
servicePointId);
if (servicePointLocation == null) { if (servicePointLocation == null) {
continue; continue;
} }
double distance = gpsCalculationService.calculateDistance( double distance =
servicePointLocation, gpsCalculationService.calculateDistance(
record.delivery()); servicePointLocation, record.delivery());
if (distance < bestScore) { if (distance < bestScore) {
bestScore = distance; bestScore = distance;
@ -298,28 +273,23 @@ public class PathFinderService {
} }
} }
if (bestDrone == null) { if (bestDrone == null) {
throw new IllegalStateException( throw new IllegalStateException("No available drone for delivery " + record.id());
"No available drone for delivery " + record.id());
} }
return bestDrone; return bestDrone;
} }
/** /**
* Break a sequence of deliveries into several trips that each respect the * Break a sequence of deliveries into several trips that each respect the drone move limit. The
* drone move limit. The deliveries should already be ordered by proximity * deliveries should already be ordered by proximity for sensible grouping.
* 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 * @throws IllegalStateException If a single delivery exceeds the drone's move limit.
* move limit.
*/ */
private List<List<MedDispatchRecRequest>> splitTrips( private List<List<MedDispatchRecRequest>> splitTrips(
List<MedDispatchRecRequest> deliveries, List<MedDispatchRecRequest> deliveries, Drone drone, LngLat servicePoint) {
Drone drone,
LngLat servicePoint) {
List<List<MedDispatchRecRequest>> trips = new ArrayList<>(); List<List<MedDispatchRecRequest>> trips = new ArrayList<>();
List<MedDispatchRecRequest> currentTrip = new ArrayList<>(); List<MedDispatchRecRequest> currentTrip = new ArrayList<>();
for (MedDispatchRecRequest delivery : deliveries) { for (MedDispatchRecRequest delivery : deliveries) {
@ -329,11 +299,11 @@ public class PathFinderService {
currentTrip.remove(currentTrip.size() - 1); currentTrip.remove(currentTrip.size() - 1);
if (currentTrip.isEmpty()) { if (currentTrip.isEmpty()) {
throw new IllegalStateException( throw new IllegalStateException(
"Delivery " + "Delivery "
delivery.id() + + delivery.id()
" exceeds drone " + + " exceeds drone "
drone.id() + + drone.id()
" move limit"); + " move limit");
} }
trips.add(new ArrayList<>(currentTrip)); trips.add(new ArrayList<>(currentTrip));
currentTrip.clear(); currentTrip.clear();
@ -352,9 +322,9 @@ public class PathFinderService {
} }
/** /**
* Build a single trip for the provided drone, including the entire flight * Build a single trip for the provided drone, including the entire flight path to every
* path to every delivery and back home. The resulting structure contains the * delivery and back home. The resulting structure contains the {@link DronePath} representation
* {@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.
* @param servicePoint Starting/ending location of the trip. * @param servicePoint Starting/ending location of the trip.
@ -363,9 +333,7 @@ public class PathFinderService {
* @see DeliveryPathResponse.DronePath * @see DeliveryPathResponse.DronePath
*/ */
private TripResult buildTrip( private TripResult buildTrip(
Drone drone, Drone drone, LngLat servicePoint, List<MedDispatchRecRequest> deliveries) {
LngLat servicePoint,
List<MedDispatchRecRequest> deliveries) {
if (deliveries == null || deliveries.isEmpty()) { if (deliveries == null || deliveries.isEmpty()) {
return null; return null;
} }
@ -390,9 +358,7 @@ public class PathFinderService {
moves += toDelivery.moves(); moves += toDelivery.moves();
if (i == deliveries.size() - 1) { if (i == deliveries.size() - 1) {
PathSegment backHome = computePath( PathSegment backHome = computePath(delivery.delivery(), servicePoint);
delivery.delivery(),
servicePoint);
backHome.appendSkippingStart(flightPath); backHome.appendSkippingStart(flightPath);
moves += backHome.moves(); moves += backHome.moves();
current = servicePoint; current = servicePoint;
@ -402,9 +368,10 @@ 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().costFinal() + drone.capability().costInitial()
(float) (drone.capability().costPerMove() * moves); + drone.capability().costFinal()
+ (float) (drone.capability().costPerMove() * moves);
DronePath path = new DronePath(drone.parseId(), flightPlans); DronePath path = new DronePath(drone.parseId(), flightPlans);
@ -412,16 +379,14 @@ public class PathFinderService {
} }
/** /**
* Estimate the number of moves a prospective trip would need by replaying * Estimate the number of moves a prospective trip would need by replaying the path calculation
* the path calculation without mutating any persistent state. * without mutating any persistent state.
* *
* @param servicePoint Trip origin. * @param servicePoint Trip origin.
* @param deliveries Deliveries that would compose the trip. * @param deliveries Deliveries that would compose the trip.
* @return Total moves required to fly the proposed itinerary. * @return Total moves required to fly the proposed itinerary.
*/ */
private int estimateTripMoves( private int estimateTripMoves(LngLat servicePoint, List<MedDispatchRecRequest> deliveries) {
LngLat servicePoint,
List<MedDispatchRecRequest> deliveries) {
if (deliveries.isEmpty()) { if (deliveries.isEmpty()) {
return 0; return 0;
} }
@ -437,8 +402,8 @@ public class PathFinderService {
} }
/** /**
* Build a path between {@code start} and {@code target} by repeatedly moving * Build a path between {@code start} and {@code target} by repeatedly moving in snapped
* in snapped increments while avoiding restricted zones. * increments while avoiding restricted zones.
* *
* @param start Start coordinate. * @param start Start coordinate.
* @param target Destination coordinate. * @param target Destination coordinate.
@ -453,8 +418,8 @@ public class PathFinderService {
positions.add(start); positions.add(start);
LngLat current = start; LngLat current = start;
int iterations = 0; int iterations = 0;
while (!gpsCalculationService.isCloseTo(current, target) && while (!gpsCalculationService.isCloseTo(current, target)
iterations < MAX_SEGMENT_ITERATIONS) { && iterations < MAX_SEGMENT_ITERATIONS) {
LngLat next = nextPosition(current, target); LngLat next = nextPosition(current, target);
if (next.isSamePoint(current)) { if (next.isSamePoint(current)) {
break; break;
@ -470,20 +435,18 @@ 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 * preferring the snapped angle closest to the desired heading that does not infiltrate a
* 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.atan2( Math.toDegrees(
target.lat() - current.lat(), Math.atan2(target.lat() - current.lat(), target.lng() - current.lng()));
target.lng() - current.lng()));
List<Angle> candidateAngles = buildAngleCandidates(desiredAngle); List<Angle> candidateAngles = buildAngleCandidates(desiredAngle);
for (Angle angle : candidateAngles) { for (Angle angle : candidateAngles) {
LngLat next = gpsCalculationService.nextPosition(current, angle); LngLat next = gpsCalculationService.nextPosition(current, angle);
@ -495,12 +458,11 @@ 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 * clockwise and counter-clockwise to explore alternative headings if the primary path is
* alternative headings if the primary path is blocked. * blocked.
* *
* @param desiredAngle Bearing in degrees between current and target * @param desiredAngle Bearing in degrees between current and target positions.
* positions.
* @return Ordered list of candidate snapped angles. * @return Ordered list of candidate snapped angles.
* @see Angle#snap(double) * @see Angle#snap(double)
*/ */
@ -532,17 +494,16 @@ public class PathFinderService {
} }
/** /**
* Representation of a computed path segment wrapping the visited positions * Representation of a computed path segment wrapping the visited positions and the number of
* and 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.
* @param moves Number of moves consumed by the path. * @param moves Number of moves consumed by the path.
*/ */
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 * Append the positions from this segment to {@code target}, skipping the first coordinate
* first coordinate as it is already represented by the last coordinate * as it is already represented by the last coordinate in the consumer path.
* in the consumer path.
* *
* @param target Mutable list to append to. * @param target Mutable list to append to.
*/ */
@ -554,9 +515,8 @@ public class PathFinderService {
} }
/** /**
* Bundle containing the calculated {@link DronePath}, total moves and * Bundle containing the calculated {@link DronePath}, total moves and financial cost for a
* financial 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,18 +1,17 @@
package io.github.js0ny.ilp_coursework.util; package io.github.js0ny.ilp_coursework.util;
import java.math.BigDecimal;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import java.math.BigDecimal;
/** /**
* Comparator for attribute values in {@code JsonNode}. * Comparator for attribute values in {@code JsonNode}.
* *
* This is a helper for dynamic querying. * <p>This is a helper for dynamic querying.
*/ */
public class AttrComparator { public class AttrComparator {
/** /**
* Helper for dynamic querying, to compare the json value with given value in * Helper for dynamic querying, to compare the json value with given value in {@code String}.
* {@code String}.
* *
* @param node The {@code JsonNode} to be compared * @param node The {@code JsonNode} to be compared
* @param attrVal The Value passed, in {@code String} * @param attrVal The Value passed, in {@code String}

View file

@ -1,5 +1,10 @@
package io.github.js0ny.ilp_coursework; package io.github.js0ny.ilp_coursework;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -7,23 +12,18 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
public class ActuatorHealthTest { public class ActuatorHealthTest {
@Autowired @Autowired private MockMvc mockMvc;
private MockMvc mockMvc;
@Test @Test
@DisplayName("GET /actuator/health -> 200 OK") @DisplayName("GET /actuator/health -> 200 OK")
void getActuator_shouldReturn200AndON() throws Exception { void getActuator_shouldReturn200AndON() throws Exception {
String endpoint = "/actuator/health"; String endpoint = "/actuator/health";
String expected = """ String expected =
"""
{ {
"status": "UP" "status": "UP"
} }

View file

@ -7,7 +7,5 @@ import org.springframework.boot.test.context.SpringBootTest;
class IlpCourseworkApplicationTests { class IlpCourseworkApplicationTests {
@Test @Test
void contextLoads() { void contextLoads() {}
}
} }

View file

@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.Angle; import io.github.js0ny.ilp_coursework.data.common.Angle;
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.common.Region;
@ -15,7 +16,7 @@ import io.github.js0ny.ilp_coursework.data.request.DistanceRequest;
import io.github.js0ny.ilp_coursework.data.request.MovementRequest; 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 java.util.List;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -26,17 +27,16 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import java.util.List;
@WebMvcTest(ApiController.class) @WebMvcTest(ApiController.class)
public class ApiControllerTest { public class ApiControllerTest {
@Autowired @Autowired private MockMvc mockMvc;
private MockMvc mockMvc;
@Autowired @Autowired private ObjectMapper objectMapper;
private ObjectMapper objectMapper;
@MockitoBean @MockitoBean private GpsCalculationService service;
private GpsCalculationService service;
@Nested @Nested
@DisplayName("GET /uid") @DisplayName("GET /uid")
@ -59,21 +59,19 @@ public class ApiControllerTest {
@Test @Test
@DisplayName("POST /distanceTo -> 200 OK") @DisplayName("POST /distanceTo -> 200 OK")
void getDistance_shouldReturn200AndDistance_whenCorrectInput() void getDistance_shouldReturn200AndDistance_whenCorrectInput() throws Exception {
throws Exception {
double expected = 5.0; double expected = 5.0;
String endpoint = "/api/v1/distanceTo"; String endpoint = "/api/v1/distanceTo";
LngLat p1 = new LngLat(0, 4.0); LngLat p1 = new LngLat(0, 4.0);
LngLat p2 = new LngLat(3.0, 0); LngLat p2 = new LngLat(3.0, 0);
var req = new DistanceRequest(p1, p2); var req = new DistanceRequest(p1, p2);
when( when(service.calculateDistance(any(LngLat.class), any(LngLat.class)))
service.calculateDistance(any(LngLat.class), any(LngLat.class)) .thenReturn(expected);
).thenReturn(expected); var mock =
var mock = mockMvc.perform( mockMvc.perform(
post(endpoint) post(endpoint)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)) .content(objectMapper.writeValueAsString(req)));
);
mock.andExpect(status().isOk()); mock.andExpect(status().isOk());
mock.andExpect(content().string(String.valueOf(expected))); mock.andExpect(content().string(String.valueOf(expected)));
@ -83,7 +81,8 @@ public class ApiControllerTest {
@DisplayName("POST /distanceTo -> 400 Bad Request: Missing Field") @DisplayName("POST /distanceTo -> 400 Bad Request: Missing Field")
void getDistance_shouldReturn400_whenMissingField() throws Exception { void getDistance_shouldReturn400_whenMissingField() throws Exception {
String endpoint = "/api/v1/distanceTo"; String endpoint = "/api/v1/distanceTo";
String req = """ String req =
"""
{ {
"position1": { "position1": {
"lng": 3.0, "lng": 3.0,
@ -91,15 +90,9 @@ public class ApiControllerTest {
} }
} }
"""; """;
when( when(service.calculateDistance(any(LngLat.class), isNull()))
service.calculateDistance(any(LngLat.class), isNull()) .thenThrow(new NullPointerException());
).thenThrow(new NullPointerException()); mockMvc.perform(post(endpoint).contentType(MediaType.APPLICATION_JSON).content(req))
mockMvc
.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(req)
)
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@ -107,18 +100,13 @@ public class ApiControllerTest {
@DisplayName("POST /distanceTo -> 400 Bad Request: Semantic errors") @DisplayName("POST /distanceTo -> 400 Bad Request: Semantic errors")
void getDistance_shouldReturn400_whenInvalidInput() throws Exception { void getDistance_shouldReturn400_whenInvalidInput() throws Exception {
String endpoint = "/api/v1/distanceTo"; String endpoint = "/api/v1/distanceTo";
String req = """ String req =
"""
{ "position1": { "lng": -300.192473, "lat": 550.946233 }, "position2": { "lng": -3202.192473, "lat": 5533.942617 } } { "position1": { "lng": -300.192473, "lat": 550.946233 }, "position2": { "lng": -3202.192473, "lat": 5533.942617 } }
"""; """;
when( when(service.calculateDistance(any(LngLat.class), isNull()))
service.calculateDistance(any(LngLat.class), isNull()) .thenThrow(new NullPointerException());
).thenThrow(new NullPointerException()); mockMvc.perform(post(endpoint).contentType(MediaType.APPLICATION_JSON).content(req))
mockMvc
.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(req)
)
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
} }
@ -129,21 +117,18 @@ public class ApiControllerTest {
@Test @Test
@DisplayName("POST /isCloseTo -> 200 OK") @DisplayName("POST /isCloseTo -> 200 OK")
void getIsCloseTo_shouldReturn200AndBoolean_whenCorrectInput() void getIsCloseTo_shouldReturn200AndBoolean_whenCorrectInput() throws Exception {
throws Exception {
boolean expected = false; boolean expected = false;
String endpoint = "/api/v1/isCloseTo"; String endpoint = "/api/v1/isCloseTo";
LngLat p1 = new LngLat(0, 4.0); LngLat p1 = new LngLat(0, 4.0);
LngLat p2 = new LngLat(3.0, 0); LngLat p2 = new LngLat(3.0, 0);
var req = new DistanceRequest(p1, p2); var req = new DistanceRequest(p1, p2);
when( when(service.isCloseTo(any(LngLat.class), any(LngLat.class))).thenReturn(expected);
service.isCloseTo(any(LngLat.class), any(LngLat.class)) var mock =
).thenReturn(expected); mockMvc.perform(
var mock = mockMvc.perform(
post(endpoint) post(endpoint)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)) .content(objectMapper.writeValueAsString(req)));
);
mock.andExpect(status().isOk()); mock.andExpect(status().isOk());
mock.andExpect(content().string(String.valueOf(expected))); mock.andExpect(content().string(String.valueOf(expected)));
@ -151,19 +136,17 @@ public class ApiControllerTest {
@Test @Test
@DisplayName("POST /isCloseTo -> 400 Bad Request: Malformed JSON ") @DisplayName("POST /isCloseTo -> 400 Bad Request: Malformed JSON ")
void getIsCloseTo_shouldReturn400_whenJsonIsMalformed() void getIsCloseTo_shouldReturn400_whenJsonIsMalformed() throws Exception {
throws Exception {
// json without a bracket // json without a bracket
String malformedJson = """ String malformedJson =
"""
{ {
"position1": { "lng": 0.0, "lat": 3.0 } "position1": { "lng": 0.0, "lat": 3.0 }
"""; """;
mockMvc mockMvc.perform(
.perform(
post("/api/v1/isCloseTo") post("/api/v1/isCloseTo")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(malformedJson) .content(malformedJson))
)
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@ -171,18 +154,13 @@ public class ApiControllerTest {
@DisplayName("POST /isCloseTo -> 400 Bad Request: Semantic errors") @DisplayName("POST /isCloseTo -> 400 Bad Request: Semantic errors")
void getIsCloseTo_shouldReturn400_whenInvalidInput() throws Exception { void getIsCloseTo_shouldReturn400_whenInvalidInput() throws Exception {
String endpoint = "/api/v1/isCloseTo"; String endpoint = "/api/v1/isCloseTo";
String req = """ String req =
"""
{ "position1": { "lng": -3004.192473, "lat": 550.946233 }, "position2": { "lng": -390.192473, "lat": 551.942617 } } { "position1": { "lng": -3004.192473, "lat": 550.946233 }, "position2": { "lng": -390.192473, "lat": 551.942617 } }
"""; """;
when( when(service.calculateDistance(any(LngLat.class), isNull()))
service.calculateDistance(any(LngLat.class), isNull()) .thenThrow(new NullPointerException());
).thenThrow(new NullPointerException()); mockMvc.perform(post(endpoint).contentType(MediaType.APPLICATION_JSON).content(req))
mockMvc
.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(req)
)
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
} }
@ -195,55 +173,47 @@ public class ApiControllerTest {
@Test @Test
@DisplayName("POST /nextPosition -> 200 OK") @DisplayName("POST /nextPosition -> 200 OK")
void getNextPosition_shouldReturn200AndCoordinate_whenCorrectInput() void getNextPosition_shouldReturn200AndCoordinate_whenCorrectInput() throws Exception {
throws Exception {
LngLat expected = new LngLat(0.00015, 0.0); LngLat expected = new LngLat(0.00015, 0.0);
LngLat p = new LngLat(0, 0); LngLat p = new LngLat(0, 0);
var req = new MovementRequest(p, 0); var req = new MovementRequest(p, 0);
when( when(service.nextPosition(any(LngLat.class), any(Angle.class))).thenReturn(expected);
service.nextPosition(any(LngLat.class), any(Angle.class)) var mock =
).thenReturn(expected); mockMvc.perform(
var mock = mockMvc.perform(
post(endpoint) post(endpoint)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)) .content(objectMapper.writeValueAsString(req)));
);
mock.andExpect(status().isOk()); mock.andExpect(status().isOk());
mock.andExpect( mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
content().json(objectMapper.writeValueAsString(expected))
);
} }
@Test @Test
@DisplayName("POST /nextPosition -> 400 Bad Request: Missing Field") @DisplayName("POST /nextPosition -> 400 Bad Request: Missing Field")
void getNextPosition_shouldReturn400_whenKeyNameError() void getNextPosition_shouldReturn400_whenKeyNameError() throws Exception {
throws Exception {
// "position" should be "start" // "position" should be "start"
String malformedJson = """ String malformedJson =
"""
{ {
"position": { "lng": 0.0, "lat": 3.0 }, "position": { "lng": 0.0, "lat": 3.0 },
"angle": 180 "angle": 180
} }
"""; """;
when(service.nextPosition(isNull(), any(Angle.class))).thenThrow( when(service.nextPosition(isNull(), any(Angle.class)))
new NullPointerException() .thenThrow(new NullPointerException());
); mockMvc.perform(
mockMvc
.perform(
post("/api/v1/nextPosition") post("/api/v1/nextPosition")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(malformedJson) .content(malformedJson))
)
.andExpect(MockMvcResultMatchers.status().isBadRequest()); .andExpect(MockMvcResultMatchers.status().isBadRequest());
} }
@Test @Test
@DisplayName("POST /nextPosition -> 400 Bad Request: Semantic errors") @DisplayName("POST /nextPosition -> 400 Bad Request: Semantic errors")
void getNextPosition_shouldReturn400_whenInvalidInput() void getNextPosition_shouldReturn400_whenInvalidInput() throws Exception {
throws Exception {
String endpoint = "/api/v1/nextPosition"; String endpoint = "/api/v1/nextPosition";
String req = """ String req =
"""
{ {
"start": { "start": {
"lng": -3.192473, "lng": -3.192473,
@ -252,15 +222,9 @@ public class ApiControllerTest {
"angle": 900 "angle": 900
} }
"""; """;
when( when(service.calculateDistance(any(LngLat.class), isNull()))
service.calculateDistance(any(LngLat.class), isNull()) .thenThrow(new NullPointerException());
).thenThrow(new NullPointerException()); mockMvc.perform(post(endpoint).contentType(MediaType.APPLICATION_JSON).content(req))
mockMvc
.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(req)
)
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
} }
@ -271,30 +235,27 @@ public class ApiControllerTest {
@Test @Test
@DisplayName("POST /isInRegion -> 200 OK") @DisplayName("POST /isInRegion -> 200 OK")
void getIsInRegion_shouldReturn200AndBoolean_whenCorrectInput() void getIsInRegion_shouldReturn200AndBoolean_whenCorrectInput() throws Exception {
throws Exception {
boolean expected = false; boolean expected = false;
String endpoint = "/api/v1/isInRegion"; String endpoint = "/api/v1/isInRegion";
var position = new LngLat(1.234, 1.222); var position = new LngLat(1.234, 1.222);
var region = new Region( var region =
new Region(
"central", "central",
List.of( List.of(
new LngLat(-3.192473, 55.946233), new LngLat(-3.192473, 55.946233),
new LngLat(-3.192473, 55.942617), new LngLat(-3.192473, 55.942617),
new LngLat(-3.184319, 55.942617), new LngLat(-3.184319, 55.942617),
new LngLat(-3.184319, 55.946233), new LngLat(-3.184319, 55.946233),
new LngLat(-3.192473, 55.946233) new LngLat(-3.192473, 55.946233)));
)
);
var req = new RegionCheckRequest(position, region); var req = new RegionCheckRequest(position, region);
when( when(service.checkIsInRegion(any(LngLat.class), any(Region.class)))
service.checkIsInRegion(any(LngLat.class), any(Region.class)) .thenReturn(expected);
).thenReturn(expected); var mock =
var mock = mockMvc.perform( mockMvc.perform(
post(endpoint) post(endpoint)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)) .content(objectMapper.writeValueAsString(req)));
);
mock.andExpect(status().isOk()); mock.andExpect(status().isOk());
mock.andExpect(content().string(String.valueOf(expected))); mock.andExpect(content().string(String.valueOf(expected)));
@ -302,52 +263,43 @@ public class ApiControllerTest {
@Test @Test
@DisplayName( @DisplayName(
"POST /isInRegion -> 400 Bad Request: Passing a list of empty vertices to isInRegion" "POST /isInRegion -> 400 Bad Request: Passing a list of empty vertices to"
) + " isInRegion")
void getIsInRegion_shouldReturn400_whenPassingIllegalArguments() void getIsInRegion_shouldReturn400_whenPassingIllegalArguments() throws Exception {
throws Exception {
var position = new LngLat(1, 1); var position = new LngLat(1, 1);
var region = new Region("illegal", List.of()); var region = new Region("illegal", List.of());
var request = new RegionCheckRequest(position, region); var request = new RegionCheckRequest(position, region);
when( when(service.checkIsInRegion(any(LngLat.class), any(Region.class)))
service.checkIsInRegion(any(LngLat.class), any(Region.class)) .thenThrow(new IllegalArgumentException("Region is not closed."));
).thenThrow(new IllegalArgumentException("Region is not closed.")); mockMvc.perform(
mockMvc
.perform(
post("/api/v1/isInRegion") post("/api/v1/isInRegion")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)) .content(objectMapper.writeValueAsString(request)))
)
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@Test @Test
@DisplayName( @DisplayName(
"POST /isInRegion -> 400 Bad Request: Passing a list of not-closing vertices to isInRegion" "POST /isInRegion -> 400 Bad Request: Passing a list of not-closing vertices to"
) + " isInRegion")
void getIsInRegion_shouldReturn400_whenPassingNotClosingVertices() void getIsInRegion_shouldReturn400_whenPassingNotClosingVertices() throws Exception {
throws Exception {
var position = new LngLat(1, 1); var position = new LngLat(1, 1);
var region = new Region( var region =
new Region(
"illegal", "illegal",
List.of( List.of(
new LngLat(1, 2), new LngLat(1, 2),
new LngLat(3, 4), new LngLat(3, 4),
new LngLat(5, 6), new LngLat(5, 6),
new LngLat(7, 8), new LngLat(7, 8),
new LngLat(9, 10) new LngLat(9, 10)));
)
);
var request = new RegionCheckRequest(position, region); var request = new RegionCheckRequest(position, region);
when( when(service.checkIsInRegion(any(LngLat.class), any(Region.class)))
service.checkIsInRegion(any(LngLat.class), any(Region.class)) .thenThrow(new IllegalArgumentException("Region is not closed."));
).thenThrow(new IllegalArgumentException("Region is not closed.")); mockMvc.perform(
mockMvc
.perform(
post("/api/v1/isInRegion") post("/api/v1/isInRegion")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)) .content(objectMapper.writeValueAsString(request)))
)
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
} }

View file

@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
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.external.Drone; import io.github.js0ny.ilp_coursework.data.external.Drone;
@ -17,10 +18,7 @@ import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
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;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
@ -31,22 +29,23 @@ import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.Map;
@WebMvcTest(DroneController.class) @WebMvcTest(DroneController.class)
public class DroneControllerTest { public class DroneControllerTest {
@Autowired @Autowired private MockMvc mockMvc;
private MockMvc mockMvc;
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@MockitoBean @MockitoBean private DroneInfoService droneInfoService;
private DroneInfoService droneInfoService;
@MockitoBean @MockitoBean private DroneAttrComparatorService droneAttrComparatorService;
private DroneAttrComparatorService droneAttrComparatorService;
@MockitoBean @MockitoBean private PathFinderService pathFinderService;
private PathFinderService pathFinderService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@ -66,14 +65,11 @@ public class DroneControllerTest {
throws Exception { throws Exception {
String endpoint = API_ENDPOINT_BASE + "true"; String endpoint = API_ENDPOINT_BASE + "true";
List<String> expected = List.of("1", "5", "8", "9"); List<String> expected = List.of("1", "5", "8", "9");
when( when(droneInfoService.dronesWithCooling(anyBoolean())).thenReturn(expected);
droneInfoService.dronesWithCooling(anyBoolean())).thenReturn(expected); var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
var mock = mockMvc.perform(
get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk()); mock.andExpect(status().isOk());
mock.andExpect( mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
content().json(objectMapper.writeValueAsString(expected)));
} }
@Test @Test
@ -82,23 +78,18 @@ public class DroneControllerTest {
throws Exception { throws Exception {
String endpoint = API_ENDPOINT_BASE + "false"; String endpoint = API_ENDPOINT_BASE + "false";
List<String> expected = List.of("2", "3", "4", "6", "7", "10"); List<String> expected = List.of("2", "3", "4", "6", "7", "10");
when( when(droneInfoService.dronesWithCooling(anyBoolean())).thenReturn(expected);
droneInfoService.dronesWithCooling(anyBoolean())).thenReturn(expected); var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
var mock = mockMvc.perform(
get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk()); mock.andExpect(status().isOk());
mock.andExpect( mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
content().json(objectMapper.writeValueAsString(expected)));
} }
@Test @Test
@DisplayName("-> 400 Bad Request") @DisplayName("-> 400 Bad Request")
void getDronesWithCooling_shouldReturn400_whenStateIsInvalid() void getDronesWithCooling_shouldReturn400_whenStateIsInvalid() throws Exception {
throws Exception {
String endpoint = API_ENDPOINT_BASE + "invalid"; String endpoint = API_ENDPOINT_BASE + "invalid";
mockMvc.perform( mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON))
get(endpoint).contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
} }
@ -111,33 +102,29 @@ public class DroneControllerTest {
@Test @Test
@DisplayName("-> 200 OK") @DisplayName("-> 200 OK")
void getDroneDetails_shouldReturn200AndJson_whenCorrectInput() void getDroneDetails_shouldReturn200AndJson_whenCorrectInput() throws Exception {
throws Exception { Drone expected =
Drone expected = new Drone("Drone 1", "1", new Drone(
"Drone 1",
"1",
new DroneCapability(true, true, 4.0f, 2000, 0.01f, 4.3f, 6.5f)); new DroneCapability(true, true, 4.0f, 2000, 0.01f, 4.3f, 6.5f));
String endpoint = API_ENDPOINT_BASE + "1"; String endpoint = API_ENDPOINT_BASE + "1";
when( when(droneInfoService.droneDetail(anyString())).thenReturn(expected);
droneInfoService.droneDetail(anyString())).thenReturn(expected); var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
var mock = mockMvc.perform(
get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk()); mock.andExpect(status().isOk());
mock.andExpect( mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
content().json(objectMapper.writeValueAsString(expected)));
} }
@Test @Test
@DisplayName("-> 404 Not Found") @DisplayName("-> 404 Not Found")
void getDroneDetails_shouldReturn404_whenDroneNotFound() void getDroneDetails_shouldReturn404_whenDroneNotFound() throws Exception {
throws Exception {
String endpoint = API_ENDPOINT_BASE + "invalidDroneId"; String endpoint = API_ENDPOINT_BASE + "invalidDroneId";
when( when(droneInfoService.droneDetail(anyString()))
droneInfoService.droneDetail(anyString())).thenThrow(new IllegalArgumentException()); .thenThrow(new IllegalArgumentException());
mockMvc.perform( mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON))
get(endpoint).contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
} }
@Nested @Nested
@ -148,15 +135,15 @@ public class DroneControllerTest {
@Test @Test
@DisplayName("capacity = 8 -> 200 OK") @DisplayName("capacity = 8 -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenCapacityIs8() void getQueryAsPath_shouldReturn200AndArrayOfString_whenCapacityIs8() throws Exception {
throws Exception {
String attrName = "capacity"; String attrName = "capacity";
String attrVal = "8"; String attrVal = "8";
List<String> expected = List.of("2", "4", "7", "9"); List<String> expected = List.of("2", "4", "7", "9");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal)) when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(get(API_ENDPOINT_BASE + attrName + "/" + attrVal) mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected))); .andExpect(content().json(objectMapper.writeValueAsString(expected)));
@ -164,15 +151,15 @@ public class DroneControllerTest {
@Test @Test
@DisplayName("heating = true -> 200 OK") @DisplayName("heating = true -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenHeatingIsTrue() void getQueryAsPath_shouldReturn200AndArrayOfString_whenHeatingIsTrue() throws Exception {
throws Exception {
String attrName = "heating"; String attrName = "heating";
String attrVal = "true"; String attrVal = "true";
List<String> expected = List.of("1", "2", "4", "5", "6", "7", "9"); List<String> expected = List.of("1", "2", "4", "5", "6", "7", "9");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal)) when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(get(API_ENDPOINT_BASE + attrName + "/" + attrVal) mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected))); .andExpect(content().json(objectMapper.writeValueAsString(expected)));
@ -180,15 +167,15 @@ public class DroneControllerTest {
@Test @Test
@DisplayName("cooling = false -> 200 OK") @DisplayName("cooling = false -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenCoolingIsFalse() void getQueryAsPath_shouldReturn200AndArrayOfString_whenCoolingIsFalse() throws Exception {
throws Exception {
String attrName = "cooling"; String attrName = "cooling";
String attrVal = "false"; String attrVal = "false";
List<String> expected = List.of("2", "3", "4", "6", "7", "10"); List<String> expected = List.of("2", "3", "4", "6", "7", "10");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal)) when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(get(API_ENDPOINT_BASE + attrName + "/" + attrVal) mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected))); .andExpect(content().json(objectMapper.writeValueAsString(expected)));
@ -196,15 +183,15 @@ public class DroneControllerTest {
@Test @Test
@DisplayName("maxMoves = 1000 -> 200 OK") @DisplayName("maxMoves = 1000 -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenMaxMovesIs1000() void getQueryAsPath_shouldReturn200AndArrayOfString_whenMaxMovesIs1000() throws Exception {
throws Exception {
String attrName = "maxMoves"; String attrName = "maxMoves";
String attrVal = "1000"; String attrVal = "1000";
List<String> expected = List.of("2", "4", "7", "9"); List<String> expected = List.of("2", "4", "7", "9");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal)) when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(get(API_ENDPOINT_BASE + attrName + "/" + attrVal) mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected))); .andExpect(content().json(objectMapper.writeValueAsString(expected)));
@ -220,7 +207,8 @@ public class DroneControllerTest {
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal)) when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(get(API_ENDPOINT_BASE + attrName + "/" + attrVal) mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected))); .andExpect(content().json(objectMapper.writeValueAsString(expected)));
@ -243,10 +231,12 @@ public class DroneControllerTest {
AttrQueryRequest[] requestBody = {req1, req2, req3}; AttrQueryRequest[] requestBody = {req1, req2, req3};
List<String> expected = List.of("8"); List<String> expected = List.of("8");
when(droneAttrComparatorService.dronesSatisfyingAttributes(any(AttrQueryRequest[].class))) when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT) mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody))) .content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk()) .andExpect(status().isOk())
@ -255,17 +245,18 @@ public class DroneControllerTest {
@Test @Test
@DisplayName("GT LT -> 200 OK") @DisplayName("GT LT -> 200 OK")
void postQuery_shouldReturn200AndArrayOfString_whenGtLtConditions() void postQuery_shouldReturn200AndArrayOfString_whenGtLtConditions() throws Exception {
throws Exception {
AttrQueryRequest req1 = new AttrQueryRequest("capacity", ">", "8"); AttrQueryRequest req1 = new AttrQueryRequest("capacity", ">", "8");
AttrQueryRequest req2 = new AttrQueryRequest("maxMoves", "<", "2000"); AttrQueryRequest req2 = new AttrQueryRequest("maxMoves", "<", "2000");
AttrQueryRequest[] requestBody = {req1, req2}; AttrQueryRequest[] requestBody = {req1, req2};
List<String> expected = List.of("5", "10"); List<String> expected = List.of("5", "10");
when(droneAttrComparatorService.dronesSatisfyingAttributes(any(AttrQueryRequest[].class))) when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT) mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody))) .content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk()) .andExpect(status().isOk())
@ -281,10 +272,12 @@ public class DroneControllerTest {
AttrQueryRequest[] requestBody = {req1, req2}; AttrQueryRequest[] requestBody = {req1, req2};
List<String> expected = List.of(); List<String> expected = List.of();
when(droneAttrComparatorService.dronesSatisfyingAttributes(any(AttrQueryRequest[].class))) when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT) mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody))) .content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk()) .andExpect(status().isOk())
@ -293,18 +286,19 @@ public class DroneControllerTest {
@Test @Test
@DisplayName("GT LT EQ -> 200 OK") @DisplayName("GT LT EQ -> 200 OK")
void postQuery_shouldReturn200AndArrayOfString_whenGtLtEqConditions() void postQuery_shouldReturn200AndArrayOfString_whenGtLtEqConditions() throws Exception {
throws Exception {
AttrQueryRequest req1 = new AttrQueryRequest("capacity", ">", "8"); AttrQueryRequest req1 = new AttrQueryRequest("capacity", ">", "8");
AttrQueryRequest req2 = new AttrQueryRequest("maxMoves", "<", "2000"); AttrQueryRequest req2 = new AttrQueryRequest("maxMoves", "<", "2000");
AttrQueryRequest req3 = new AttrQueryRequest("cooling", "=", "true"); AttrQueryRequest req3 = new AttrQueryRequest("cooling", "=", "true");
AttrQueryRequest[] requestBody = {req1, req2, req3}; AttrQueryRequest[] requestBody = {req1, req2, req3};
List<String> expected = List.of("5"); List<String> expected = List.of("5");
when(droneAttrComparatorService.dronesSatisfyingAttributes(any(AttrQueryRequest[].class))) when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT) mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody))) .content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk()) .andExpect(status().isOk())
@ -324,14 +318,21 @@ public class DroneControllerTest {
throws Exception { throws Exception {
var reqs = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f); var reqs = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
var delivery = new LngLat(-3.00, 55.121); var delivery = new LngLat(-3.00, 55.121);
var record = new MedDispatchRecRequest(123, LocalDate.parse("2025-12-22"), LocalTime.parse("14:30"), reqs, delivery); var record =
new MedDispatchRecRequest(
123,
LocalDate.parse("2025-12-22"),
LocalTime.parse("14:30"),
reqs,
delivery);
MedDispatchRecRequest[] requestBody = {record}; MedDispatchRecRequest[] requestBody = {record};
List<String> expected = List.of("1", "2", "6", "7", "9"); List<String> expected = List.of("1", "2", "6", "7", "9");
when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class))) when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class)))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT) mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody))) .content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk()) .andExpect(status().isOk())
@ -342,24 +343,30 @@ public class DroneControllerTest {
@DisplayName("Treat Null as False (Cooling) -> 200 OK") @DisplayName("Treat Null as False (Cooling) -> 200 OK")
void postQueryAvailableDrones_shouldReturn200AndArrayOfString_whenCoolingIsNull() void postQueryAvailableDrones_shouldReturn200AndArrayOfString_whenCoolingIsNull()
throws Exception { throws Exception {
var requestMap = Map.of( var requestMap =
"id", 123, Map.of(
"date", "2025-12-22", "id",
"time", "14:30", 123,
"requirements", Map.of( "date",
"2025-12-22",
"time",
"14:30",
"requirements",
Map.of(
"capacity", 0.75, "capacity", 0.75,
"heating", true, "heating", true,
"maxCost", 13.5 "maxCost", 13.5));
)
);
List<String> expected = List.of("1", "2", "6", "7", "9"); List<String> expected = List.of("1", "2", "6", "7", "9");
when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class))) when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class)))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT) mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new Object[]{requestMap}))) .content(
objectMapper.writeValueAsString(
new Object[] {requestMap})))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected))); .andExpect(content().json(objectMapper.writeValueAsString(expected)));
} }
@ -370,11 +377,23 @@ public class DroneControllerTest {
throws Exception { throws Exception {
var reqs1 = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f); var reqs1 = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
var delivery1 = new LngLat(-3.00, 55.121); var delivery1 = new LngLat(-3.00, 55.121);
var record1 = new MedDispatchRecRequest(123, LocalDate.parse("2025-12-22"), LocalTime.parse("14:30"), reqs1, delivery1); var record1 =
new MedDispatchRecRequest(
123,
LocalDate.parse("2025-12-22"),
LocalTime.parse("14:30"),
reqs1,
delivery1);
var reqs2 = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f); var reqs2 = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
var delivery2 = new LngLat(-3.00, 55.121); var delivery2 = new LngLat(-3.00, 55.121);
var record2 = new MedDispatchRecRequest(456, LocalDate.parse("2025-12-25"), LocalTime.parse("11:30"), reqs2, delivery2); var record2 =
new MedDispatchRecRequest(
456,
LocalDate.parse("2025-12-25"),
LocalTime.parse("11:30"),
reqs2,
delivery2);
MedDispatchRecRequest[] requestBody = {record1, record2}; MedDispatchRecRequest[] requestBody = {record1, record2};
List<String> expected = List.of("2", "7", "9"); List<String> expected = List.of("2", "7", "9");
@ -382,7 +401,8 @@ public class DroneControllerTest {
when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class))) when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class)))
.thenReturn(expected); .thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT) mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody))) .content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk()) .andExpect(status().isOk())

View file

@ -11,11 +11,7 @@ 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.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;
import java.net.URI;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
@ -26,11 +22,16 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
public class DroneInfoServiceTest { public class DroneInfoServiceTest {
@Mock @Mock private RestTemplate restTemplate;
private RestTemplate restTemplate;
private DroneInfoService droneInfoService; private DroneInfoService droneInfoService;
@ -134,30 +135,46 @@ public class DroneInfoServiceTest {
@DisplayName("dronesMatchesRequirements(MedDispatchRecRequest[]) tests") @DisplayName("dronesMatchesRequirements(MedDispatchRecRequest[]) tests")
class DronesMatchesRequirementsTests { class DronesMatchesRequirementsTests {
private ServicePointDrones[] getMockServicePointDrones() { private ServicePointDrones[] getMockServicePointDrones() {
TimeWindow[] timeWindows = { new TimeWindow(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.of(17, 0)) }; TimeWindow[] timeWindows = {
new TimeWindow(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.of(17, 0))
};
DroneAvailability drone1Avail = new DroneAvailability("1", timeWindows); DroneAvailability drone1Avail = new DroneAvailability("1", timeWindows);
ServicePointDrones spd = new ServicePointDrones(1, new DroneAvailability[] { drone1Avail }); ServicePointDrones spd =
return new ServicePointDrones[] { spd }; new ServicePointDrones(1, new DroneAvailability[] {drone1Avail});
return new ServicePointDrones[] {spd};
} }
@Test @Test
@DisplayName("Should return drones matching a single requirement") @DisplayName("Should return drones matching a single requirement")
void dronesMatchesRequirements_shouldReturnMatchingDrones_forSingleRequirement() { void dronesMatchesRequirements_shouldReturnMatchingDrones_forSingleRequirement() {
// Arrange // Arrange
var drones = new Drone[] { var drones =
new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)), new Drone[] {
new Drone("Drone 2", "2", new DroneCapability(false, true, 5, 2000, 2, 2, 2)) new Drone(
"Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)),
new Drone(
"Drone 2", "2", new DroneCapability(false, true, 5, 2000, 2, 2, 2))
}; };
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class)).thenReturn(drones); when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
when(restTemplate.getForObject(URI.create(baseUrl + "drones-for-service-points"), .thenReturn(drones);
ServicePointDrones[].class)).thenReturn(getMockServicePointDrones()); when(restTemplate.getForObject(
URI.create(baseUrl + "drones-for-service-points"),
ServicePointDrones[].class))
.thenReturn(getMockServicePointDrones());
var requirement = new MedDispatchRecRequest.MedRequirement(8, true, false, 100); var requirement = new MedDispatchRecRequest.MedRequirement(8, true, false, 100);
var record = new MedDispatchRecRequest(1, LocalDate.now().with(DayOfWeek.MONDAY), LocalTime.of(10, 0), var record =
requirement, new LngLat(0, 0)); new MedDispatchRecRequest(
1,
LocalDate.now().with(DayOfWeek.MONDAY),
LocalTime.of(10, 0),
requirement,
new LngLat(0, 0));
// Act // Act
List<String> result = droneInfoService.dronesMatchesRequirements(new MedDispatchRecRequest[] { record }); List<String> result =
droneInfoService.dronesMatchesRequirements(
new MedDispatchRecRequest[] {record});
// Assert // Assert
assertThat(result).containsExactly("1"); assertThat(result).containsExactly("1");
@ -167,19 +184,30 @@ public class DroneInfoServiceTest {
@DisplayName("Should return empty list if no drones match") @DisplayName("Should return empty list if no drones match")
void dronesMatchesRequirements_shouldReturnEmptyList_whenNoDronesMatch() { void dronesMatchesRequirements_shouldReturnEmptyList_whenNoDronesMatch() {
// Arrange // Arrange
var drones = new Drone[] { var drones =
new Drone("Drone 1", "1", new DroneCapability(true, true, 5, 1000, 1, 1, 1)), new Drone[] {
new Drone("Drone 2", "2", new DroneCapability(false, true, 5, 2000, 2, 2, 2)) new Drone(
"Drone 1", "1", new DroneCapability(true, true, 5, 1000, 1, 1, 1)),
new Drone(
"Drone 2", "2", new DroneCapability(false, true, 5, 2000, 2, 2, 2))
}; };
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class)).thenReturn(drones); when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(drones);
// No need to mock drones-for-service-points as it won't be called // No need to mock drones-for-service-points as it won't be called
var requirement = new MedDispatchRecRequest.MedRequirement(10, true, false, 100); var requirement = new MedDispatchRecRequest.MedRequirement(10, true, false, 100);
var record = new MedDispatchRecRequest(1, LocalDate.now().with(DayOfWeek.MONDAY), LocalTime.of(10, 0), var record =
requirement, new LngLat(0, 0)); new MedDispatchRecRequest(
1,
LocalDate.now().with(DayOfWeek.MONDAY),
LocalTime.of(10, 0),
requirement,
new LngLat(0, 0));
// Act // Act
List<String> result = droneInfoService.dronesMatchesRequirements(new MedDispatchRecRequest[] { record }); List<String> result =
droneInfoService.dronesMatchesRequirements(
new MedDispatchRecRequest[] {record});
// Assert // Assert
assertThat(result).isEmpty(); assertThat(result).isEmpty();
@ -194,7 +222,8 @@ public class DroneInfoServiceTest {
// Act // Act
List<String> resultNull = droneInfoService.dronesMatchesRequirements(null); List<String> resultNull = droneInfoService.dronesMatchesRequirements(null);
List<String> resultEmpty = droneInfoService.dronesMatchesRequirements(new MedDispatchRecRequest[0]); List<String> resultEmpty =
droneInfoService.dronesMatchesRequirements(new MedDispatchRecRequest[0]);
// Assert // Assert
assertThat(resultNull).containsExactly("1", "2", "3"); assertThat(resultNull).containsExactly("1", "2", "3");

View file

@ -7,12 +7,14 @@ import static org.assertj.core.api.AssertionsForClassTypes.within;
import io.github.js0ny.ilp_coursework.data.common.Angle; import io.github.js0ny.ilp_coursework.data.common.Angle;
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.common.Region;
import java.util.List;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List;
public class GpsCalculationServiceTest { public class GpsCalculationServiceTest {
private static final double STEP = 0.00015; private static final double STEP = 0.00015;
@ -114,9 +116,7 @@ public class GpsCalculationServiceTest {
} }
@Test @Test
@DisplayName( @DisplayName("True: Two points are close to each other and near threshold")
"True: Two points are close to each other and near threshold"
)
void isCloseTo_shouldReturnTrue_whenCloseAndSmallerThanThreshold() { void isCloseTo_shouldReturnTrue_whenCloseAndSmallerThanThreshold() {
var p1 = new LngLat(0.0, 0.0); var p1 = new LngLat(0.0, 0.0);
var p2 = new LngLat(0.0, 0.00014); var p2 = new LngLat(0.0, 0.00014);
@ -160,20 +160,12 @@ public class GpsCalculationServiceTest {
var actual = service.nextPosition(start, angle); var actual = service.nextPosition(start, angle);
assertThat(actual.lng()).isCloseTo( assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
expected.lng(), assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
within(PRECISION)
);
assertThat(actual.lat()).isCloseTo(
expected.lat(),
within(PRECISION)
);
} }
@Test @Test
@DisplayName( @DisplayName("Cardinal Direction: nextPosition in North direction (90 degrees)")
"Cardinal Direction: nextPosition in North direction (90 degrees)"
)
void nextPosition_shouldMoveNorth_forAngle90() { void nextPosition_shouldMoveNorth_forAngle90() {
var start = new LngLat(0.0, 0.0); var start = new LngLat(0.0, 0.0);
Angle angle = new Angle(90); Angle angle = new Angle(90);
@ -182,20 +174,12 @@ public class GpsCalculationServiceTest {
var actual = service.nextPosition(start, angle); var actual = service.nextPosition(start, angle);
assertThat(actual.lng()).isCloseTo( assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
expected.lng(), assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
within(PRECISION)
);
assertThat(actual.lat()).isCloseTo(
expected.lat(),
within(PRECISION)
);
} }
@Test @Test
@DisplayName( @DisplayName("Cardinal Direction: nextPosition in West direction (180 degrees)")
"Cardinal Direction: nextPosition in West direction (180 degrees)"
)
void nextPosition_shouldMoveWest_forAngle180() { void nextPosition_shouldMoveWest_forAngle180() {
var start = new LngLat(0.0, 0.0); var start = new LngLat(0.0, 0.0);
Angle angle = new Angle(180); Angle angle = new Angle(180);
@ -205,20 +189,12 @@ public class GpsCalculationServiceTest {
var actual = service.nextPosition(start, angle); var actual = service.nextPosition(start, angle);
assertThat(actual.lng()).isCloseTo( assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
expected.lng(), assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
within(PRECISION)
);
assertThat(actual.lat()).isCloseTo(
expected.lat(),
within(PRECISION)
);
} }
@Test @Test
@DisplayName( @DisplayName("Cardinal Direction: nextPosition in South direction (270 degrees)")
"Cardinal Direction: nextPosition in South direction (270 degrees)"
)
void nextPosition_shouldMoveSouth_forAngle270() { void nextPosition_shouldMoveSouth_forAngle270() {
var start = new LngLat(0.0, 0.0); var start = new LngLat(0.0, 0.0);
Angle angle = new Angle(270); Angle angle = new Angle(270);
@ -228,20 +204,12 @@ public class GpsCalculationServiceTest {
var actual = service.nextPosition(start, angle); var actual = service.nextPosition(start, angle);
assertThat(actual.lng()).isCloseTo( assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
expected.lng(), assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
within(PRECISION)
);
assertThat(actual.lat()).isCloseTo(
expected.lat(),
within(PRECISION)
);
} }
@Test @Test
@DisplayName( @DisplayName("Intercardinal Direction: nextPosition in Northeast direction (45 degrees)")
"Intercardinal Direction: nextPosition in Northeast direction (45 degrees)"
)
void nextPosition_shouldMoveNortheast_forAngle45() { void nextPosition_shouldMoveNortheast_forAngle45() {
var start = new LngLat(0.0, 0.0); var start = new LngLat(0.0, 0.0);
Angle angle = new Angle(45); Angle angle = new Angle(45);
@ -252,47 +220,37 @@ public class GpsCalculationServiceTest {
var actual = service.nextPosition(start, angle); var actual = service.nextPosition(start, angle);
assertThat(actual.lng()).isCloseTo( assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
expected.lng(), assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
within(PRECISION)
);
assertThat(actual.lat()).isCloseTo(
expected.lat(),
within(PRECISION)
);
} }
@Nested @Nested
@DisplayName( @DisplayName("Test for checkIsInRegion(LngLatDto, RegionDto) -> boolean")
"Test for checkIsInRegion(LngLatDto, RegionDto) -> boolean"
)
class CheckIsInRegionTests { class CheckIsInRegionTests {
public static final Region RECTANGLE_REGION = new Region( public static final Region RECTANGLE_REGION =
new Region(
"rectangle", "rectangle",
List.of( List.of(
new LngLat(0.0, 0.0), new LngLat(0.0, 0.0),
new LngLat(2.0, 0.0), new LngLat(2.0, 0.0),
new LngLat(2.0, 2.0), new LngLat(2.0, 2.0),
new LngLat(0.0, 2.0), new LngLat(0.0, 2.0),
new LngLat(0.0, 0.0) new LngLat(0.0, 0.0)));
)
);
@Test @Test
@DisplayName("General Case: Given Example for Testing") @DisplayName("General Case: Given Example for Testing")
void isInRegion_shouldReturnFalse_givenPolygonCentral() { void isInRegion_shouldReturnFalse_givenPolygonCentral() {
var position = new LngLat(1.234, 1.222); var position = new LngLat(1.234, 1.222);
var region = new Region( var region =
new Region(
"central", "central",
List.of( List.of(
new LngLat(-3.192473, 55.946233), new LngLat(-3.192473, 55.946233),
new LngLat(-3.192473, 55.942617), new LngLat(-3.192473, 55.942617),
new LngLat(-3.184319, 55.942617), new LngLat(-3.184319, 55.942617),
new LngLat(-3.184319, 55.946233), new LngLat(-3.184319, 55.946233),
new LngLat(-3.192473, 55.946233) new LngLat(-3.192473, 55.946233)));
)
);
boolean expected = false; boolean expected = false;
boolean actual = service.checkIsInRegion(position, region); boolean actual = service.checkIsInRegion(position, region);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
@ -303,10 +261,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_forSimpleRectangle() { void isInRegion_shouldReturnTrue_forSimpleRectangle() {
var position = new LngLat(1.0, 1.0); var position = new LngLat(1.0, 1.0);
boolean expected = true; boolean expected = true;
boolean actual = service.checkIsInRegion( boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
position,
RECTANGLE_REGION
);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
} }
@ -315,10 +270,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnFalse_forSimpleRectangle() { void isInRegion_shouldReturnFalse_forSimpleRectangle() {
var position = new LngLat(3.0, 1.0); var position = new LngLat(3.0, 1.0);
boolean expected = false; boolean expected = false;
boolean actual = service.checkIsInRegion( boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
position,
RECTANGLE_REGION
);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
} }
@ -326,7 +278,8 @@ public class GpsCalculationServiceTest {
@DisplayName("General Case: Simple Hexagon") @DisplayName("General Case: Simple Hexagon")
void isInRegion_shouldReturnTrue_forSimpleHexagon() { void isInRegion_shouldReturnTrue_forSimpleHexagon() {
var position = new LngLat(2.0, 2.0); var position = new LngLat(2.0, 2.0);
var region = new Region( var region =
new Region(
"hexagon", "hexagon",
List.of( List.of(
new LngLat(1.0, 0.0), new LngLat(1.0, 0.0),
@ -335,9 +288,7 @@ public class GpsCalculationServiceTest {
new LngLat(4.0, 4.0), new LngLat(4.0, 4.0),
new LngLat(1.0, 4.0), new LngLat(1.0, 4.0),
new LngLat(0.0, 2.0), new LngLat(0.0, 2.0),
new LngLat(1.0, 0.0) new LngLat(1.0, 0.0)));
)
);
boolean expected = true; boolean expected = true;
boolean actual = service.checkIsInRegion(position, region); boolean actual = service.checkIsInRegion(position, region);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
@ -347,15 +298,14 @@ public class GpsCalculationServiceTest {
@DisplayName("Edge Case: Small Triangle") @DisplayName("Edge Case: Small Triangle")
void isInRegion_shouldReturnTrue_forSmallTriangle() { void isInRegion_shouldReturnTrue_forSmallTriangle() {
var position = new LngLat(0.00001, 0.00001); var position = new LngLat(0.00001, 0.00001);
var region = new Region( var region =
new Region(
"triangle", "triangle",
List.of( List.of(
new LngLat(0.0, 0.0), new LngLat(0.0, 0.0),
new LngLat(0.0001, 0.0), new LngLat(0.0001, 0.0),
new LngLat(0.00005, 0.0001), new LngLat(0.00005, 0.0001),
new LngLat(0.0, 0.0) new LngLat(0.0, 0.0)));
)
);
boolean expected = true; boolean expected = true;
boolean actual = service.checkIsInRegion(position, region); boolean actual = service.checkIsInRegion(position, region);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
@ -366,10 +316,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_whenPointOnLowerEdge() { void isInRegion_shouldReturnTrue_whenPointOnLowerEdge() {
var position = new LngLat(0.0, 1.0); var position = new LngLat(0.0, 1.0);
boolean expected = true; boolean expected = true;
boolean actual = service.checkIsInRegion( boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
position,
RECTANGLE_REGION
);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
} }
@ -378,10 +325,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_whenPointOnUpperEdge() { void isInRegion_shouldReturnTrue_whenPointOnUpperEdge() {
var position = new LngLat(2.0, 1.0); var position = new LngLat(2.0, 1.0);
boolean expected = true; boolean expected = true;
boolean actual = service.checkIsInRegion( boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
position,
RECTANGLE_REGION
);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
} }
@ -390,10 +334,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_whenPointOnLeftEdge() { void isInRegion_shouldReturnTrue_whenPointOnLeftEdge() {
var position = new LngLat(0.0, 1.0); var position = new LngLat(0.0, 1.0);
boolean expected = true; boolean expected = true;
boolean actual = service.checkIsInRegion( boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
position,
RECTANGLE_REGION
);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
} }
@ -402,10 +343,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_whenPointOnLowerVertex() { void isInRegion_shouldReturnTrue_whenPointOnLowerVertex() {
var position = new LngLat(0.0, 0.0); var position = new LngLat(0.0, 0.0);
boolean expected = true; boolean expected = true;
boolean actual = service.checkIsInRegion( boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
position,
RECTANGLE_REGION
);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
} }
@ -414,10 +352,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_whenPointOnUpperVertex() { void isInRegion_shouldReturnTrue_whenPointOnUpperVertex() {
var position = new LngLat(2.0, 2.0); var position = new LngLat(2.0, 2.0);
boolean expected = true; boolean expected = true;
boolean actual = service.checkIsInRegion( boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
position,
RECTANGLE_REGION
);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
} }
@ -425,15 +360,15 @@ public class GpsCalculationServiceTest {
@DisplayName("Edge Case: Region not forming polygon") @DisplayName("Edge Case: Region not forming polygon")
void isInRegion_shouldThrowExceptions_whenRegionNotFormingPolygon() { void isInRegion_shouldThrowExceptions_whenRegionNotFormingPolygon() {
var position = new LngLat(2.0, 2.0); var position = new LngLat(2.0, 2.0);
var region = new Region( var region =
new Region(
"line", "line",
List.of( List.of(
new LngLat(0.0, 0.0), new LngLat(0.0, 0.0),
new LngLat(0.0001, 0.0), new LngLat(0.0001, 0.0),
new LngLat(0.0, 0.0) new LngLat(0.0, 0.0)));
) assertThatThrownBy(
); () -> {
assertThatThrownBy(() -> {
service.checkIsInRegion(position, region); service.checkIsInRegion(position, region);
}) })
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
@ -444,17 +379,17 @@ public class GpsCalculationServiceTest {
@DisplayName("Edge Case: Region is not closed") @DisplayName("Edge Case: Region is not closed")
void isInRegion_shouldThrowExceptions_whenRegionNotClose() { void isInRegion_shouldThrowExceptions_whenRegionNotClose() {
var position = new LngLat(2.0, 2.0); var position = new LngLat(2.0, 2.0);
var region = new Region( var region =
new Region(
"rectangle", "rectangle",
List.of( List.of(
new LngLat(0.0, 0.0), new LngLat(0.0, 0.0),
new LngLat(2.0, 0.0), new LngLat(2.0, 0.0),
new LngLat(2.0, 2.0), new LngLat(2.0, 2.0),
new LngLat(0.0, 2.0), new LngLat(0.0, 2.0),
new LngLat(0.0, -1.0) new LngLat(0.0, -1.0)));
) assertThatThrownBy(
); () -> {
assertThatThrownBy(() -> {
service.checkIsInRegion(position, region); service.checkIsInRegion(position, region);
}) })
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
@ -466,7 +401,8 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldThrowExceptions_whenListIsEmpty() { void isInRegion_shouldThrowExceptions_whenListIsEmpty() {
var position = new LngLat(2.0, 2.0); var position = new LngLat(2.0, 2.0);
var region = new Region("rectangle", List.of()); var region = new Region("rectangle", List.of());
assertThatThrownBy(() -> { assertThatThrownBy(
() -> {
service.checkIsInRegion(position, region); service.checkIsInRegion(position, region);
}) })
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)

View file

@ -6,6 +6,7 @@ import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
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;
@ -18,17 +19,19 @@ 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;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest.MedRequirement; import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest.MedRequirement;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse; import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException; import java.io.IOException;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime; import java.time.LocalTime;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class PathFinderServiceTest { class PathFinderServiceTest {
@ -37,8 +40,7 @@ class PathFinderServiceTest {
private static final LngLat SERVICE_POINT_COORD = new LngLat(0.0, 0.0); private static final LngLat SERVICE_POINT_COORD = new LngLat(0.0, 0.0);
private final ObjectMapper mapper = new ObjectMapper(); private final ObjectMapper mapper = new ObjectMapper();
@Mock @Mock private DroneInfoService droneInfoService;
private DroneInfoService droneInfoService;
private PathFinderService pathFinderService; private PathFinderService pathFinderService;
@ -46,66 +48,38 @@ class PathFinderServiceTest {
void setUpPathFinder() { void setUpPathFinder() {
GpsCalculationService gpsCalculationService = new GpsCalculationService(); GpsCalculationService gpsCalculationService = new GpsCalculationService();
DroneCapability capability = new DroneCapability( DroneCapability capability = new DroneCapability(false, true, 5.0f, 10, 0.1f, 0.5f, 0.5f);
false,
true,
5.0f,
10,
0.1f,
0.5f,
0.5f
);
Drone drone = new Drone("Test Drone", DRONE_ID, capability); Drone drone = new Drone("Test Drone", DRONE_ID, capability);
ServicePoint servicePoint = new ServicePoint( ServicePoint servicePoint =
new ServicePoint(
"Test Point", "Test Point",
1, 1,
new LngLatAlt( new LngLatAlt(SERVICE_POINT_COORD.lng(), SERVICE_POINT_COORD.lat(), 50.0));
SERVICE_POINT_COORD.lng(), DroneAvailability availability =
SERVICE_POINT_COORD.lat(), new DroneAvailability(
50.0
)
);
DroneAvailability availability = new DroneAvailability(
DRONE_ID, DRONE_ID,
new TimeWindow[] { new TimeWindow[] {
new TimeWindow( new TimeWindow(DayOfWeek.MONDAY, LocalTime.MIDNIGHT, LocalTime.MAX),
DayOfWeek.MONDAY, });
LocalTime.MIDNIGHT, ServicePointDrones servicePointDrones =
LocalTime.MAX new ServicePointDrones(servicePoint.id(), new DroneAvailability[] {availability});
),
}
);
ServicePointDrones servicePointDrones = new ServicePointDrones(
servicePoint.id(),
new DroneAvailability[] { availability }
);
when(droneInfoService.fetchAllDrones()).thenReturn(List.of(drone)); when(droneInfoService.fetchAllDrones()).thenReturn(List.of(drone));
when(droneInfoService.fetchServicePoints()).thenReturn( when(droneInfoService.fetchServicePoints()).thenReturn(List.of(servicePoint));
List.of(servicePoint) when(droneInfoService.fetchDronesForServicePoints())
); .thenReturn(List.of(servicePointDrones));
when(droneInfoService.fetchDronesForServicePoints()).thenReturn( when(droneInfoService.fetchRestrictedAreas())
List.of(servicePointDrones) .thenReturn(Collections.<RestrictedArea>emptyList());
); when(droneInfoService.droneMatchesRequirement(any(), any())).thenReturn(true);
when(droneInfoService.fetchRestrictedAreas()).thenReturn( pathFinderService = new PathFinderService(gpsCalculationService, droneInfoService);
Collections.<RestrictedArea>emptyList()
);
when(droneInfoService.droneMatchesRequirement(any(), any())).thenReturn(
true
);
pathFinderService = new PathFinderService(
gpsCalculationService,
droneInfoService
);
} }
@Test @Test
void calculateDeliveryPath_shouldStayWithinSingleTripBudget() { void calculateDeliveryPath_shouldStayWithinSingleTripBudget() {
MedDispatchRecRequest request = createSampleRequest(); MedDispatchRecRequest request = createSampleRequest();
DeliveryPathResponse response = pathFinderService.calculateDeliveryPath( DeliveryPathResponse response =
new MedDispatchRecRequest[] { request } pathFinderService.calculateDeliveryPath(new MedDispatchRecRequest[] {request});
);
assertThat(response.totalMoves()).isGreaterThan(0); assertThat(response.totalMoves()).isGreaterThan(0);
assertThat(response.totalMoves()).isLessThanOrEqualTo(10); assertThat(response.totalMoves()).isLessThanOrEqualTo(10);
@ -115,24 +89,18 @@ class PathFinderServiceTest {
assertThat(dronePath.deliveries()).hasSize(1); assertThat(dronePath.deliveries()).hasSize(1);
var recordedPath = dronePath.deliveries().get(0).flightPath(); var recordedPath = dronePath.deliveries().get(0).flightPath();
assertThat(recordedPath.get(0)).isEqualTo(SERVICE_POINT_COORD); assertThat(recordedPath.get(0)).isEqualTo(SERVICE_POINT_COORD);
assertThat( assertThat(samePoint(recordedPath.get(recordedPath.size() - 1), SERVICE_POINT_COORD))
samePoint(
recordedPath.get(recordedPath.size() - 1),
SERVICE_POINT_COORD
)
)
.isTrue(); .isTrue();
assertThat(hasHoverAt(recordedPath, request.delivery())).isTrue(); assertThat(hasHoverAt(recordedPath, request.delivery())).isTrue();
} }
@Test @Test
void calculateDeliveryPathAsGeoJson_shouldReturnFeatureCollection() void calculateDeliveryPathAsGeoJson_shouldReturnFeatureCollection() throws IOException {
throws IOException {
MedDispatchRecRequest request = createSampleRequest(); MedDispatchRecRequest request = createSampleRequest();
String geoJson = pathFinderService.calculateDeliveryPathAsGeoJson( String geoJson =
new MedDispatchRecRequest[] { request } pathFinderService.calculateDeliveryPathAsGeoJson(
); new MedDispatchRecRequest[] {request});
JsonNode root = mapper.readTree(geoJson); JsonNode root = mapper.readTree(geoJson);
assertThat(root.get("type").asText()).isEqualTo("FeatureCollection"); assertThat(root.get("type").asText()).isEqualTo("FeatureCollection");
@ -151,10 +119,7 @@ class PathFinderServiceTest {
double startLng = coordinates.get(0).get(0).asDouble(); double startLng = coordinates.get(0).get(0).asDouble();
double startLat = coordinates.get(0).get(1).asDouble(); double startLat = coordinates.get(0).get(1).asDouble();
assertThat( assertThat(samePoint(new LngLat(startLng, startLat), SERVICE_POINT_COORD)).isTrue();
samePoint(new LngLat(startLng, startLat), SERVICE_POINT_COORD)
)
.isTrue();
} }
private MedDispatchRecRequest createSampleRequest() { private MedDispatchRecRequest createSampleRequest() {
@ -163,8 +128,7 @@ class PathFinderServiceTest {
LocalDate.of(2025, 1, 6), LocalDate.of(2025, 1, 6),
LocalTime.of(12, 0), LocalTime.of(12, 0),
new MedRequirement(0.5f, false, true, 50.0f), new MedRequirement(0.5f, false, true, 50.0f),
new LngLat(SERVICE_POINT_COORD.lng() + 0.0003, SERVICE_POINT_COORD.lat()) new LngLat(SERVICE_POINT_COORD.lng() + 0.0003, SERVICE_POINT_COORD.lat()));
);
} }
private boolean hasHoverAt(List<LngLat> path, LngLat target) { private boolean hasHoverAt(List<LngLat> path, LngLat target) {
@ -178,9 +142,6 @@ class PathFinderServiceTest {
private boolean samePoint(LngLat a, LngLat b) { private boolean samePoint(LngLat a, LngLat b) {
double threshold = 1e-9; double threshold = 1e-9;
return ( return (Math.abs(a.lng() - b.lng()) < threshold && Math.abs(a.lat() - b.lat()) < threshold);
Math.abs(a.lng() - b.lng()) < threshold &&
Math.abs(a.lat() - b.lat()) < threshold
);
} }
} }