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.RegionCheckRequest;
import io.github.js0ny.ilp_coursework.service.GpsCalculationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
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.
* <p>
* This class handles incoming HTTP requests for the API under {@code /api/v1} path (defined in CW1)
* This is responsible for mapping requests to the appropriate service method and returning the results as responses.
* The business logic is delegated to {@link GpsCalculationService}
*
* <p>This class handles incoming HTTP requests for the API under {@code /api/v1} path (defined in
* CW1) This is responsible for mapping requests to the appropriate service method and returning the
* results as responses. The business logic is delegated to {@link GpsCalculationService}
*/
@RestController
@RequestMapping("/api/v1")
@ -27,9 +28,11 @@ public class ApiController {
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) {
this.gpsService = gpsService;
@ -62,7 +65,8 @@ public class ApiController {
* Handles POST requests to check if the two coordinates are close to each other
*
* @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")
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
*
* @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
*/
@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.DroneInfoService;
import io.github.js0ny.ilp_coursework.service.PathFinderService;
import java.util.List;
import org.springframework.http.ResponseEntity;
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.
* <p>
* This class handles incoming HTTP requests for the API under {@code /api/v1}
* path (defined in CW2)
* The business logic is delegated to {@link DroneInfoService}
*
* <p>This class handles incoming HTTP requests for the API under {@code /api/v1} path (defined in
* CW2) The business logic is delegated to {@link DroneInfoService}
*/
@RestController
@RequestMapping("/api/v1")
@ -28,37 +28,33 @@ public class DroneController {
private final PathFinderService pathFinderService;
/**
* Constructor of the {@code DroneController} with the business logic dependency
* {@code DroneInfoService}
* <p>
* We handle the {@code baseUrl} here. Use a predefined URL if the environment
* variable {@code ILP_ENDPOINT}
* is not given.
* Constructor of the {@code DroneController} with the business logic dependency {@code
* DroneInfoService}
*
* <p>We handle the {@code baseUrl} here. Use a predefined URL if the environment variable
* {@code ILP_ENDPOINT} is not given.
*
* @param droneService The service component that contains all business logic
*/
public DroneController(
DroneInfoService droneService,
DroneAttrComparatorService droneAttrComparatorService,
PathFinderService pathFinderService
) {
DroneInfoService droneService,
DroneAttrComparatorService droneAttrComparatorService,
PathFinderService pathFinderService) {
this.droneInfoService = droneService;
this.droneAttrComparatorService = droneAttrComparatorService;
this.pathFinderService = pathFinderService;
}
/**
* Handles GET requests to retrieve an array of drones (identified by id) that
* has the capability of cooling
* Handles GET requests to retrieve an array of drones (identified by id) that has the
* capability of cooling
*
* @param state The path variable that indicates the return should have or not
* have the capability
* @param state The path variable that indicates the return should have or not have the
* capability
* @return An array of drone id with cooling capability.
*/
@GetMapping("/dronesWithCooling/{state}")
public List<String> getDronesWithCoolingCapability(
@PathVariable boolean state
) {
public List<String> getDronesWithCoolingCapability(@PathVariable boolean state) {
return droneInfoService.dronesWithCooling(state);
}
@ -66,8 +62,8 @@ public class DroneController {
* Handles GET requests to retrieve the drone detail identified by id
*
* @param id The id of the drone to be queried.
* @return 200 with {@link Drone}-style json if success, 404 if {@code id}
* not found, 400 otherwise
* @return 200 with {@link Drone}-style json if success, 404 if {@code id} not found, 400
* otherwise
*/
@GetMapping("/droneDetails/{id}")
public ResponseEntity<Drone> getDroneDetail(@PathVariable String id) {
@ -80,51 +76,36 @@ public class DroneController {
}
/**
* Handles GET requests to retrieve an array of drone ids that
* {@code capability.attrName = attrVal}
* Handles GET requests to retrieve an array of drone ids that {@code capability.attrName =
* attrVal}
*
* @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
* @return An array of drone id that matches the attribute name and value
*/
@GetMapping("/queryAsPath/{attrName}/{attrVal}")
public List<String> getIdByAttrMap(
@PathVariable String attrName,
@PathVariable String attrVal
) {
return droneAttrComparatorService.dronesWithAttribute(
attrName,
attrVal
);
@PathVariable String attrName, @PathVariable String attrVal) {
return droneAttrComparatorService.dronesWithAttribute(attrName, attrVal);
}
@PostMapping("/query")
public List<String> getIdByAttrMapPost(
@RequestBody AttrQueryRequest[] attrComparators
) {
return droneAttrComparatorService.dronesSatisfyingAttributes(
attrComparators
);
public List<String> getIdByAttrMapPost(@RequestBody AttrQueryRequest[] attrComparators) {
return droneAttrComparatorService.dronesSatisfyingAttributes(attrComparators);
}
@PostMapping("/queryAvailableDrones")
public List<String> queryAvailableDrones(
@RequestBody MedDispatchRecRequest[] records
) {
public List<String> queryAvailableDrones(@RequestBody MedDispatchRecRequest[] records) {
return droneInfoService.dronesMatchesRequirements(records);
}
@PostMapping("/calcDeliveryPath")
public DeliveryPathResponse calculateDeliveryPath(
@RequestBody MedDispatchRecRequest[] record
) {
public DeliveryPathResponse calculateDeliveryPath(@RequestBody MedDispatchRecRequest[] record) {
return pathFinderService.calculateDeliveryPath(record);
}
@PostMapping("/calcDeliveryPathAsGeoJson")
public String calculateDeliveryPathAsGeoJson(
@RequestBody MedDispatchRecRequest[] record
) {
public String calculateDeliveryPathAsGeoJson(@RequestBody MedDispatchRecRequest[] record) {
return pathFinderService.calculateDeliveryPathAsGeoJson(record);
}
}

View file

@ -1,14 +1,13 @@
package io.github.js0ny.ilp_coursework.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1")
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}).
*
* @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
* is not a fly zone.
*
* @param upper The upper bound of the altitude range. If {@code upper = -1}, then the region is not
* 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 {
if (degrees < 0 || degrees >= 360) {
throw new IllegalArgumentException(
"Angle must be in range [0, 360). Got: " + degrees);
throw new IllegalArgumentException("Angle must be in range [0, 360). Got: " + degrees);
}
// 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
// So we need to check if the remainder is small enough, or close enough to STEP
// (handling negative errors)
if (Math.abs(remainder) > EPSILON &&
Math.abs(remainder - STEP) > EPSILON) {
if (Math.abs(remainder) > EPSILON && Math.abs(remainder - STEP) > EPSILON) {
throw new IllegalArgumentException(
"Angle must be a multiple of 22.5 (one of the 16 major directions). Got: " +
degrees);
"Angle must be a multiple of 22.5 (one of the 16 major directions). Got: "
+ degrees);
}
}
public static Angle fromIndex(int index) {
if (index < 0 || index > 15) {
throw new IllegalArgumentException(
"Direction index must be between 0 and 15");
throw new IllegalArgumentException("Direction index must be between 0 and 15");
}
return new Angle(index * STEP);
}
@ -64,5 +61,4 @@ public record Angle(double degrees) {
public double toRadians() {
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.LocalTime;
public record DroneAvailability(
String id,
TimeWindow[] availability) {
public record DroneAvailability(String id, TimeWindow[] availability) {
public boolean checkAvailability(DayOfWeek day, LocalTime time) {

View file

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

View file

@ -1,8 +1,8 @@
package io.github.js0ny.ilp_coursework.data.common;
/**
* Represents the data transfer object for a point or coordinate
* that defines by a longitude and latitude
* Represents the data transfer object for a point or coordinate that defines by a longitude and
* latitude
*
* @param lng longitude of the coordinate/point
* @param lat latitude of the coordinate/point
@ -13,14 +13,12 @@ public record LngLat(double lng, double lat) {
public LngLat {
if (lat < -90 || lat > 90) {
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) {
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) {
return false;
}
return (
Math.abs(lng - other.lng()) < EPSILON &&
Math.abs(lat - other.lat()) < EPSILON
);
return (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;
/**
* Represents the data transfer object for a point or coordinate
* that defines by a longitude and latitude
* Represents the data transfer object for a point or coordinate that defines by a longitude and
* latitude
*
* @param lng longitude of the coordinate/point
* @param lat latitude 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;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import java.util.List;
import java.util.Objects;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
import java.util.Objects;
/**
* Represents the data transfer object for a region definition
* <p>
* This record encapsulates the data for calculating if a coordinate is inside
* the region
* <p>
* A built-in method {@code isClosedTo} is defined to check this DTO is valid or
* not in the mean of closing polygon
*
* @param name The human-readable name for the region
* <p>This record encapsulates the data for calculating if a coordinate is inside the region
*
* <p>A built-in method {@code isClosedTo} is defined to check this DTO is valid or not in the mean
* of closing polygon
*
* @param name The human-readable name for the region
* @param vertices list of coordinates that forms a polygon as a region.
* <p>
* In order to define a valid region, the last element of the
* list should be the same as the first, or
* known as closed
* <p>In order to define a valid region, the last element of the list should be the same as the
* first, or known as closed
* @see RegionCheckRequest
* @see io.github.js0ny.ilp_coursework.service.GpsCalculationService#checkIsInRegion(LngLat,
* Region)
* @see io.github.js0ny.ilp_coursework.service.GpsCalculationService#checkIsInRegion(LngLat, Region)
*/
public record Region(String name, List<LngLat> vertices) {
/**
* 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;
/**
* Method to check if the region has a valid polygon by checking if the
* {@code vertices} forms a closed polygon
* Method to check if the region has a valid polygon by checking if the {@code vertices} forms a
* closed polygon
*
* @return {@code true} if the {@code vertices} are able to form a polygon and
* form a closed polygon
* @return {@code true} if the {@code vertices} are able to form a polygon and form a closed
* polygon
*/
public boolean isClosed() {
if (vertices == null || vertices.size() < MINIMUM_VERTICES) {
@ -56,13 +51,10 @@ public record Region(String name, List<LngLat> vertices) {
try {
ObjectMapper mapper = new ObjectMapper();
List<List<Double>> ring = vertices.stream()
.map(v -> List.of(v.lng(), v.lat()))
.toList();
List<List<Double>> ring =
vertices.stream().map(v -> List.of(v.lng(), v.lat())).toList();
return Map.of(
"type", "Polygon",
"coordinates", List.of(ring));
return Map.of("type", "Polygon", "coordinates", List.of(ring));
} catch (Exception 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.LocalTime;
public record TimeWindow(
DayOfWeek dayOfWeek,
LocalTime from,
LocalTime until) {
}
public record TimeWindow(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;
/**
* Represents the data transfer object for a drone, gained from the endpoints
*/
public record Drone(
String name,
String id,
DroneCapability capability) {
/** Represents the data transfer object for a drone, gained from the endpoints */
public record Drone(String name, String id, DroneCapability capability) {
public int parseId() {
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.LngLatAlt;
import io.github.js0ny.ilp_coursework.data.common.Region;
import java.util.ArrayList;
import java.util.List;
public record RestrictedArea(
String name,
int id,
AltitudeRange limits,
LngLatAlt[] vertices
) {
public record RestrictedArea(String name, int id, AltitudeRange limits, LngLatAlt[] vertices) {
public Region toRegion() {
List<LngLat> vertices2D = new ArrayList<>();
for (var vertex : vertices) {

View file

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

View file

@ -3,5 +3,4 @@ package io.github.js0ny.ilp_coursework.data.request;
// TODO: Convert operator to Enum
// 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.
* <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
*
* @param position1 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;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import java.time.LocalDate;
@ -8,17 +9,7 @@ import java.time.LocalTime;
@JsonIgnoreProperties(ignoreUnknown = true)
public record MedDispatchRecRequest(
int id,
LocalDate date,
LocalTime time,
MedRequirement requirements,
LngLat delivery) {
int id, LocalDate date, LocalTime time, MedRequirement requirements, LngLat delivery) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record MedRequirement(
float capacity,
boolean cooling,
boolean heating,
float maxCost
) {
}
public record MedRequirement(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.
* <p>
* This record encapsulates the data for endpoint /api/v1/nextPosition and serves as the data contract for
* this API operation
*
* <p>This record encapsulates the data for endpoint /api/v1/nextPosition and serves as the data
* contract for this API operation
*
* @param start The starting coordinate of the movement
* @param angle The angle to movement in degree. This corresponds to compass directions.
* For example: 0 for East, 90 for North
* @param angle The angle to movement in degree. This corresponds to compass directions. For
* example: 0 for East, 90 for North
* @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.
* <p>
* This record encapsulates the data for endpoint /api/v1/isInRegion and serves as the data contract for
* this API operation
*
* <p>This record encapsulates the data for endpoint /api/v1/isInRegion and serves as the data
* contract for this API operation
*
* <p>
*
* @param position The coordinate to be checked
* @param region The region for the check.
* This is a nested object represented by {@link Region}
* @param region The region for the check. This is a nested object represented by {@link Region}
* @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;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import java.util.List;
public record DeliveryPathResponse(
float totalCost,
int totalMoves,
DronePath[] dronePaths
) {
public record DeliveryPathResponse(float totalCost, int totalMoves, DronePath[] dronePaths) {
public record DronePath(int droneId, List<Delivery> deliveries) {
public record Delivery(int deliveryId, List<LngLat> flightPath) {}
}

View file

@ -1,8 +1,5 @@
package io.github.js0ny.ilp_coursework.exception;
import java.util.Map;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.RestControllerAdvice;
/**
* Class that handles exception or failed request. Map all error requests to 400.
*/
import java.util.Map;
import java.util.Optional;
/** Class that handles exception or failed request. Map all error requests to 400. */
@RestControllerAdvice
public class GlobalExceptionHandler {
/// Use a logger to save logs instead of passing them to user
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)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ -31,8 +30,8 @@ public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleIllegalArgument(IllegalArgumentException ex) {
String errorMessage = Optional.ofNullable(ex.getMessage())
.orElse("Invalid argument provided.");
String errorMessage =
Optional.ofNullable(ex.getMessage()).orElse("Invalid argument provided.");
log.warn("Illegal argument in request: {}", errorMessage);
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.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest;
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.util.ArrayList;
import java.util.Arrays;
@ -14,8 +19,6 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class DroneAttrComparatorService {
@ -24,14 +27,11 @@ public class DroneAttrComparatorService {
private final String dronesEndpoint = "drones";
private final RestTemplate restTemplate = new RestTemplate();
/**
* Constructor, handles the base url here.
*/
/** Constructor, handles the base url here. */
public DroneAttrComparatorService() {
String baseUrl = System.getenv("ILP_ENDPOINT");
if (baseUrl == null || baseUrl.isBlank()) {
this.baseUrl =
"https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
this.baseUrl = "https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
} else {
// Defensive: Add '/' to the end of the URL
if (!baseUrl.endsWith("/")) {
@ -43,11 +43,11 @@ public class DroneAttrComparatorService {
/**
* 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 attrVal the attribute value to filter on
* @param attrVal the attribute value to filter on
* @return array of drone ids matching the attribute name and value
* @see #dronesWithAttributeCompared(String, String, AttrOperator)
* @see io.github.js0ny.ilp_coursework.controller.DroneController#getIdByAttrMap
@ -58,26 +58,19 @@ public class DroneAttrComparatorService {
}
/**
* Return an array of ids of drones which matches all given complex comparing
* rules
* Return an array of ids of drones which matches all given complex comparing rules
*
* @param attrComparators The filter rule with Name, Value and Operator
* @return array of drone ids that matches all rules
*/
public List<String> dronesSatisfyingAttributes(
AttrQueryRequest[] attrComparators
) {
public List<String> dronesSatisfyingAttributes(AttrQueryRequest[] attrComparators) {
Set<String> matchingDroneIds = null;
for (var comparator : attrComparators) {
String attribute = comparator.attribute();
String operator = comparator.operator();
String value = comparator.value();
AttrOperator op = AttrOperator.fromString(operator);
List<String> ids = dronesWithAttributeCompared(
attribute,
value,
op
);
List<String> ids = dronesWithAttributeCompared(attribute, value, op);
if (matchingDroneIds == null) {
matchingDroneIds = new HashSet<>(ids);
} else {
@ -92,25 +85,20 @@ public class DroneAttrComparatorService {
/**
* Helper that wraps the dynamic querying with different comparison operators
* <p>
* This method act as a concatenation of
* {@link io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode, String,
*
* <p>This method act as a concatenation of {@link
* io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode, String,
* AttrOperator)}
*
* @param attrName the attribute name to filter on
* @param attrVal the attribute value to filter on
* @param op the comparison operator
* @return array of drone ids matching the attribute name and value (filtered by
* {@code op})
* @see io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode,
* String,
* AttrOperator)
* @param attrVal the attribute value to filter on
* @param op the comparison operator
* @return array of drone ids matching the attribute name and value (filtered by {@code op})
* @see io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode, String,
* AttrOperator)
*/
private List<String> dronesWithAttributeCompared(
String attrName,
String attrVal,
AttrOperator op
) {
String attrName, String attrVal, AttrOperator op) {
URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
// This is required to make sure the response is valid
Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class);
@ -124,17 +112,18 @@ public class DroneAttrComparatorService {
ObjectMapper mapper = new ObjectMapper();
return Arrays.stream(drones)
.filter(drone -> {
JsonNode node = mapper.valueToTree(drone);
JsonNode attrNode = node.findValue(attrName);
if (attrNode != null) {
// Manually handle different types of JsonNode
return isValueMatched(attrNode, attrVal, op);
} else {
return false;
}
})
.map(Drone::id)
.collect(Collectors.toList());
.filter(
drone -> {
JsonNode node = mapper.valueToTree(drone);
JsonNode attrNode = node.findValue(attrName);
if (attrNode != null) {
// Manually handle different types of JsonNode
return isValueMatched(attrNode, attrVal, op);
} else {
return false;
}
})
.map(Drone::id)
.collect(Collectors.toList());
}
}

View file

@ -2,6 +2,7 @@ package io.github.js0ny.ilp_coursework.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
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.ServicePointDrones;
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.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class DroneInfoService {
private final String baseUrl;
private final String dronesForServicePointsEndpoint =
"drones-for-service-points";
private final String dronesForServicePointsEndpoint = "drones-for-service-points";
public static final String servicePointsEndpoint = "service-points";
public static final String restrictedAreasEndpoint = "restricted-areas";
private final RestTemplate restTemplate;
/**
* Constructor, handles the base url here.
*/
/** Constructor, handles the base url here. */
public DroneInfoService() {
this(new RestTemplate());
}
@ -41,8 +41,7 @@ public class DroneInfoService {
this.restTemplate = restTemplate;
String baseUrl = System.getenv("ILP_ENDPOINT");
if (baseUrl == null || baseUrl.isBlank()) {
this.baseUrl =
"https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
this.baseUrl = "https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
} else {
// Defensive: Add '/' to the end of the URL
if (!baseUrl.endsWith("/")) {
@ -54,13 +53,14 @@ public class DroneInfoService {
/**
* 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
* @return if {@code state} is true, return ids of drones with cooling
* capability, else without cooling
* @see io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean)
* @return if {@code state} is true, return ids of drones with cooling capability, else without
* cooling
* @see
* io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean)
*/
public List<String> dronesWithCooling(boolean state) {
// URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
@ -71,25 +71,22 @@ public class DroneInfoService {
return new ArrayList<>();
}
return drones
.stream()
.filter(drone -> drone.capability().cooling() == state)
.map(Drone::id)
.collect(Collectors.toList());
return drones.stream()
.filter(drone -> drone.capability().cooling() == state)
.map(Drone::id)
.collect(Collectors.toList());
}
/**
* 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
* @return drone json body of given id
* @throws NullPointerException when cannot fetch available drones from
* remote
* @throws IllegalArgumentException when drone with given {@code id} cannot be
* found
* this should lead to a 404
* @throws NullPointerException when cannot fetch available drones from remote
* @throws IllegalArgumentException when drone with given {@code id} cannot be found this should
* lead to a 404
* @see io.github.js0ny.ilp_coursework.controller.DroneController#getDroneDetail(String)
*/
public Drone droneDetail(String id) {
@ -102,16 +99,14 @@ public class DroneInfoService {
}
// This will result in 404
throw new IllegalArgumentException(
"drone with that ID cannot be found"
);
throw new IllegalArgumentException("drone with that ID cannot be found");
}
/**
* Return an array of ids of drones that match all the requirements in the
* medical dispatch records
* <p>
* Associated service method with
* Return an array of ids of drones that match all the requirements in the medical dispatch
* records
*
* <p>Associated service method with
*
* @param rec array of medical dispatch records
* @return List of drone ids that match all the requirements
@ -121,51 +116,43 @@ public class DroneInfoService {
List<Drone> drones = fetchAllDrones();
if (rec == null || rec.length == 0) {
return drones
.stream()
.filter(Objects::nonNull)
.map(Drone::id)
.collect(Collectors.toList());
return drones.stream()
.filter(Objects::nonNull)
.map(Drone::id)
.collect(Collectors.toList());
}
/*
* Traverse and filter drones, pass every record's requirement to helper
*/
return drones
.stream()
.filter(d -> d != null && d.capability() != null)
.filter(d ->
Arrays.stream(rec)
.filter(r -> r != null && r.requirements() != null)
.allMatch(r -> droneMatchesRequirement(d, r))
)
.map(Drone::id)
.collect(Collectors.toList());
return drones.stream()
.filter(d -> d != null && d.capability() != null)
.filter(
d ->
Arrays.stream(rec)
.filter(r -> r != null && r.requirements() != null)
.allMatch(r -> droneMatchesRequirement(d, r)))
.map(Drone::id)
.collect(Collectors.toList());
}
/**
* Helper to check if a drone meets the requirement of a medical dispatch.
*
* @param drone the drone to be checked
* @param drone the drone to be checked
* @param record the medical dispatch record containing the requirement
* @return true if the drone meets the requirement, false otherwise
* @throws IllegalArgumentException when record requirements or drone capability
* is invalid (capacity and id cannot be null
* in {@code MedDispathRecDto})
* @throws IllegalArgumentException when record requirements or drone capability is invalid
* (capacity and id cannot be null in {@code MedDispathRecDto})
*/
public boolean droneMatchesRequirement(
Drone drone,
MedDispatchRecRequest record
) {
public boolean droneMatchesRequirement(Drone drone, MedDispatchRecRequest record) {
var requirements = record.requirements();
if (requirements == null) {
throw new IllegalArgumentException("requirements cannot be null");
}
var capability = drone.capability();
if (capability == null) {
throw new IllegalArgumentException(
"drone capability cannot be null"
);
throw new IllegalArgumentException("drone capability cannot be null");
}
float requiredCapacity = requirements.capacity();
@ -187,11 +174,7 @@ public class DroneInfoService {
// Conditions: All requirements matched + availability matched, use helper
// For minimal privilege, only pass drone id to check availability
return (
matchesCooling &&
matchesHeating &&
checkAvailability(drone.id(), record)
); // &&
return (matchesCooling && matchesHeating && checkAvailability(drone.id(), record)); // &&
// checkCost(drone, record) // checkCost is more expensive than
// checkAvailability
}
@ -200,21 +183,13 @@ public class DroneInfoService {
* 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 record the medical dispatch record containing the required date and
* time
* @param record the medical dispatch record containing the required date and time
* @return true if the drone is available, false otherwise
*/
private boolean checkAvailability(
String droneId,
MedDispatchRecRequest record
) {
URI droneUrl = URI.create(baseUrl).resolve(
dronesForServicePointsEndpoint
);
ServicePointDrones[] servicePoints = restTemplate.getForObject(
droneUrl,
ServicePointDrones[].class
);
private boolean checkAvailability(String droneId, MedDispatchRecRequest record) {
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints =
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
LocalDate requiredDate = record.date();
DayOfWeek requiredDay = requiredDate.getDayOfWeek();
@ -232,13 +207,9 @@ public class DroneInfoService {
}
private LngLat queryServicePointLocationByDroneId(String droneId) {
URI droneUrl = URI.create(baseUrl).resolve(
dronesForServicePointsEndpoint
);
ServicePointDrones[] servicePoints = restTemplate.getForObject(
droneUrl,
ServicePointDrones[].class
);
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints =
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
assert servicePoints != null;
for (var sp : servicePoints) {
@ -253,14 +224,10 @@ public class DroneInfoService {
@Nullable
private LngLat queryServicePointLocation(int id) {
URI servicePointUrl = URI.create(baseUrl).resolve(
servicePointsEndpoint
);
URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
ServicePoint[] servicePoints = restTemplate.getForObject(
servicePointUrl,
ServicePoint[].class
);
ServicePoint[] servicePoints =
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null;
for (var sp : servicePoints) {
@ -282,49 +249,32 @@ public class DroneInfoService {
}
public List<RestrictedArea> fetchRestrictedAreas() {
URI restrictedUrl = URI.create(baseUrl).resolve(
restrictedAreasEndpoint
);
RestrictedArea[] restrictedAreas = restTemplate.getForObject(
restrictedUrl,
RestrictedArea[].class
);
URI restrictedUrl = URI.create(baseUrl).resolve(restrictedAreasEndpoint);
RestrictedArea[] restrictedAreas =
restTemplate.getForObject(restrictedUrl, RestrictedArea[].class);
assert restrictedAreas != null;
return Arrays.asList(restrictedAreas);
}
public List<String> fetchRestrictedAreasInGeoJson()
throws JsonProcessingException {
public List<String> fetchRestrictedAreasInGeoJson() throws JsonProcessingException {
var mapper = new ObjectMapper();
var ras = fetchRestrictedAreas();
var geoJson = ras
.stream()
.map(RestrictedArea::toRegion)
.map(Region::toGeoJson)
.toList();
var geoJson = ras.stream().map(RestrictedArea::toRegion).map(Region::toGeoJson).toList();
return Collections.singletonList(mapper.writeValueAsString(geoJson));
}
public List<ServicePoint> fetchServicePoints() {
URI servicePointUrl = URI.create(baseUrl).resolve(
servicePointsEndpoint
);
ServicePoint[] servicePoints = restTemplate.getForObject(
servicePointUrl,
ServicePoint[].class
);
URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
ServicePoint[] servicePoints =
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null;
return Arrays.asList(servicePoints);
}
public List<ServicePointDrones> fetchDronesForServicePoints() {
URI servicePointDronesUrl = URI.create(baseUrl).resolve(
dronesForServicePointsEndpoint
);
ServicePointDrones[] servicePointDrones = restTemplate.getForObject(
servicePointDronesUrl,
ServicePointDrones[].class
);
URI servicePointDronesUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePointDrones =
restTemplate.getForObject(servicePointDronesUrl, ServicePointDrones[].class);
assert servicePointDrones != null;
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.MovementRequest;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import java.util.List;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Class that handles calculations about Coordinates
*
@ -24,6 +26,7 @@ public class GpsCalculationService {
* @see #nextPosition(LngLat, double)
*/
private static final double STEP = 0.00015;
/**
* 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;
/**
* Calculate the Euclidean distance between {@code position1} and
* {@code position2}, which are coordinates
* defined as {@link LngLat}
* Calculate the Euclidean distance between {@code position1} and {@code position2}, which are
* coordinates defined as {@link LngLat}
*
* @param position1 The coordinate of the first position
* @param position2 The coordinate of the second position
* @return The Euclidean distance between {@code position1} and
* {@code position2}
* @return The Euclidean distance between {@code position1} and {@code position2}
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getDistance(DistanceRequest)
*/
public double calculateDistance(LngLat position1, LngLat position2) {
@ -55,17 +56,14 @@ public class GpsCalculationService {
}
/**
* Check if {@code position1} and
* {@code position2} are close to each other, the threshold is < 0.00015
* Check if {@code position1} and {@code position2} are close to each other, the threshold is <
* 0.00015
*
* <p>
* Note that = 0.00015 will be counted as not close to and will return {@code
* false}
* <p>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 position2 The coordinate of the second position
* @return {@code true} if {@code position1} and
* {@code position2} are close to each other
* @return {@code true} if {@code position1} and {@code position2} are close to each other
* @see #CLOSE_THRESHOLD
* @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
* {@code angle}, with step size
* 0.00015
* Returns the next position moved from {@code start} in the direction with {@code angle}, with
* step size 0.00015
*
* @param start The coordinate of the original start point.
* @param angle The direction to be moved in angle.
@ -93,19 +90,18 @@ public class GpsCalculationService {
}
/**
* Used to check if the given {@code position}
* is inside the {@code region}, on edge and vertex is considered as inside.
* Used to check if the given {@code position} is inside the {@code region}, on edge and vertex
* is considered as inside.
*
* @param position The coordinate of the position.
* @param region A {@link Region} that contains name and a list of
* {@code LngLatDto}
* @param region A {@link Region} that contains name and a list of {@code LngLatDto}
* @return {@code true} if {@code position} is inside the {@code region}.
* @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()
*/
public boolean checkIsInRegion(LngLat position, Region region)
throws IllegalArgumentException {
public boolean checkIsInRegion(LngLat position, Region region) throws IllegalArgumentException {
if (!region.isClosed()) {
// call method from RegionDto to check if 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
* to check if inside the polygon
* Helper function to {@code checkIsInRegion}, use of ray-casting algorithm to check if inside
* the polygon
*
* @param point The point to check
* @param polygon The region that forms a polygon to check if {@code point}
* sits inside.
* @return If the {@code point} sits inside the {@code polygon} then
* return {@code true}
* @param point The point to check
* @param polygon The region that forms a polygon to check if {@code point} sits inside.
* @return If the {@code point} sits inside the {@code polygon} then return {@code true}
* @see #isPointOnEdge(LngLat, LngLat, LngLat)
* @see #checkIsInRegion(LngLat, Region)
*/
@ -154,9 +148,7 @@ public class GpsCalculationService {
}
double xIntersection =
a.lng() +
((point.lat() - a.lat()) * (b.lng() - a.lng())) /
(b.lat() - a.lat());
a.lng() + ((point.lat() - a.lat()) * (b.lng() - a.lng())) / (b.lat() - a.lat());
if (xIntersection > point.lng()) {
++intersections;
@ -170,8 +162,7 @@ public class GpsCalculationService {
/**
* Helper function from {@code rayCasting} that used to simply calculation <br>
* Used to check if point {@code p} is on the edge formed by
* {@code a} and {@code b}
* Used to check if point {@code p} is on the edge formed by {@code a} and {@code b}
*
* @param p point to be checked on 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) {
// Cross product: (p - a) × (b - a)
double crossProduct =
(p.lng() - a.lng()) * (b.lat() - a.lat()) -
(p.lat() - a.lat()) * (b.lng() - a.lng());
(p.lng() - a.lng()) * (b.lat() - a.lat())
- (p.lat() - a.lat()) * (b.lng() - a.lng());
if (Math.abs(crossProduct) > 1e-9) {
return false;
}
boolean isWithinLng =
p.lng() >= Math.min(a.lng(), b.lng()) &&
p.lng() <= Math.max(a.lng(), b.lng());
p.lng() >= Math.min(a.lng(), b.lng()) && p.lng() <= Math.max(a.lng(), b.lng());
boolean isWithinLat =
p.lat() >= Math.min(a.lat(), b.lat()) &&
p.lat() <= Math.max(a.lat(), b.lat());
p.lat() >= Math.min(a.lat(), b.lat()) && p.lat() <= Math.max(a.lat(), b.lat());
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.DronePath;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath.Delivery;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@ -27,7 +30,6 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
/**
* Class that handles calculations about deliverypath
@ -40,9 +42,8 @@ import org.springframework.stereotype.Service;
public class PathFinderService {
/**
* Hard stop on how many pathfinding iterations we attempt for a single
* segment before bailing, useful for preventing infinite loops caused by
* precision quirks or unexpected map data.
* Hard stop on how many pathfinding iterations we attempt for a single segment before bailing,
* useful for preventing infinite loops caused by precision quirks or unexpected map data.
*
* @see #computePath(LngLat, LngLat)
*/
@ -59,17 +60,14 @@ public class PathFinderService {
private final List<Region> restrictedRegions;
/**
* Constructor for PathFinderService. The dependencies are injected by
* Spring and the constructor pre-computes reference maps used throughout the
* request lifecycle.
* Constructor for PathFinderService. The dependencies are injected by Spring and the
* constructor pre-computes reference maps used throughout the request lifecycle.
*
* @param gpsCalculationService Service handling geometric operations.
* @param droneInfoService Service that exposes drone metadata and
* capability information.
* @param droneInfoService Service that exposes drone metadata and capability information.
*/
public PathFinderService(
GpsCalculationService gpsCalculationService,
DroneInfoService droneInfoService) {
GpsCalculationService gpsCalculationService, DroneInfoService droneInfoService) {
this.gpsCalculationService = gpsCalculationService;
this.droneInfoService = droneInfoService;
this.objectMapper = new ObjectMapper();
@ -79,11 +77,11 @@ public class PathFinderService {
this.drones = droneInfoService.fetchAllDrones();
List<ServicePoint> servicePoints = droneInfoService.fetchServicePoints();
List<ServicePointDrones> servicePointAssignments = droneInfoService.fetchDronesForServicePoints();
List<ServicePointDrones> servicePointAssignments =
droneInfoService.fetchDronesForServicePoints();
List<RestrictedArea> restrictedAreas = droneInfoService.fetchRestrictedAreas();
this.droneById = this.drones.stream().collect(
Collectors.toMap(Drone::id, drone -> drone));
this.droneById = this.drones.stream().collect(Collectors.toMap(Drone::id, drone -> drone));
this.droneServicePointMap = new HashMap<>();
for (ServicePointDrones assignment : servicePointAssignments) {
@ -94,40 +92,33 @@ public class PathFinderService {
if (availability == null || availability.id() == null) {
continue;
}
droneServicePointMap.put(
availability.id(),
assignment.servicePointId());
droneServicePointMap.put(availability.id(), assignment.servicePointId());
}
}
this.servicePointLocations = servicePoints
.stream()
.collect(
Collectors.toMap(ServicePoint::id, sp -> new LngLat(sp.location())));
this.servicePointLocations =
servicePoints.stream()
.collect(
Collectors.toMap(
ServicePoint::id, sp -> new LngLat(sp.location())));
this.restrictedRegions = restrictedAreas
.stream()
.map(RestrictedArea::toRegion)
.toList();
this.restrictedRegions = restrictedAreas.stream().map(RestrictedArea::toRegion).toList();
}
/**
* Produce a delivery plan for the provided dispatch records. Deliveries are
* grouped per compatible drone and per trip to satisfy each drone move
* limit.
* Produce a delivery plan for the provided dispatch records. Deliveries are grouped per
* compatible drone and per trip to satisfy each drone move limit.
*
* @param records Dispatch records to be fulfilled.
* @return Aggregated path response with cost and move totals.
* @see #calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[])
*/
public DeliveryPathResponse calculateDeliveryPath(
MedDispatchRecRequest[] records) {
public DeliveryPathResponse calculateDeliveryPath(MedDispatchRecRequest[] records) {
if (records == null || records.length == 0) {
return new DeliveryPathResponse(0f, 0, new DronePath[0]);
}
Map<String, List<MedDispatchRecRequest>> assigned = assignDeliveries(
records);
Map<String, List<MedDispatchRecRequest>> assigned = assignDeliveries(records);
List<DronePath> paths = new ArrayList<>();
float totalCost = 0f;
@ -148,25 +139,20 @@ public class PathFinderService {
continue;
}
List<MedDispatchRecRequest> sortedDeliveries = entry
.getValue()
.stream()
.sorted(
Comparator.comparingDouble(rec -> gpsCalculationService.calculateDistance(
servicePointLocation,
rec.delivery())))
.toList();
List<MedDispatchRecRequest> sortedDeliveries =
entry.getValue().stream()
.sorted(
Comparator.comparingDouble(
rec ->
gpsCalculationService.calculateDistance(
servicePointLocation, rec.delivery())))
.toList();
List<List<MedDispatchRecRequest>> trips = splitTrips(
sortedDeliveries,
drone,
servicePointLocation);
List<List<MedDispatchRecRequest>> trips =
splitTrips(sortedDeliveries, drone, servicePointLocation);
for (List<MedDispatchRecRequest> trip : trips) {
TripResult result = buildTrip(
drone,
servicePointLocation,
trip);
TripResult result = buildTrip(drone, servicePointLocation, trip);
if (result != null) {
totalCost += result.cost();
totalMoves += result.moves();
@ -175,23 +161,18 @@ public class PathFinderService {
}
}
return new DeliveryPathResponse(
totalCost,
totalMoves,
paths.toArray(new DronePath[0]));
return new DeliveryPathResponse(totalCost, totalMoves, paths.toArray(new DronePath[0]));
}
/**
* Convenience wrapper around {@link #calculateDeliveryPath} that serializes
* the result into a GeoJSON FeatureCollection suitable for mapping
* visualization.
* Convenience wrapper around {@link #calculateDeliveryPath} that serializes the result into a
* GeoJSON FeatureCollection suitable for mapping visualization.
*
* @param records Dispatch records to be fulfilled.
* @return GeoJSON payload representing every delivery flight path.
* @throws IllegalStateException When the payload cannot be serialized.
*/
public String calculateDeliveryPathAsGeoJson(
MedDispatchRecRequest[] records) {
public String calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[] records) {
DeliveryPathResponse response = calculateDeliveryPath(records);
Map<String, Object> featureCollection = new LinkedHashMap<>();
featureCollection.put("type", "FeatureCollection");
@ -232,16 +213,13 @@ public class PathFinderService {
try {
return objectMapper.writeValueAsString(featureCollection);
} catch (JsonProcessingException e) {
throw new IllegalStateException(
"Failed to generate GeoJSON payload",
e);
throw new IllegalStateException("Failed to generate GeoJSON payload", e);
}
}
/**
* Group dispatch records by their assigned drone, ensuring every record is
* routed through {@link #findBestDrone(MedDispatchRecRequest)} exactly once
* and discarding invalid entries.
* Group dispatch records by their assigned drone, ensuring every record is routed through
* {@link #findBestDrone(MedDispatchRecRequest)} exactly once and discarding invalid entries.
*
* @param records Dispatch records to be grouped.
* @return Map keyed by drone ID with the deliveries it should service.
@ -254,16 +232,14 @@ public class PathFinderService {
continue;
}
String droneId = findBestDrone(record);
assignments
.computeIfAbsent(droneId, id -> new ArrayList<>())
.add(record);
assignments.computeIfAbsent(droneId, id -> new ArrayList<>()).add(record);
}
return assignments;
}
/**
* Choose the best drone for the provided record. Currently that equates to
* picking the closest compatible drone to the delivery location.
* Choose the best drone for the provided record. Currently that equates to picking the closest
* compatible drone to the delivery location.
*
* @param record Dispatch record that needs fulfillment.
* @return Identifier of the drone that should fly the mission.
@ -282,15 +258,14 @@ public class PathFinderService {
if (servicePointId == null) {
continue;
}
LngLat servicePointLocation = servicePointLocations.get(
servicePointId);
LngLat servicePointLocation = servicePointLocations.get(servicePointId);
if (servicePointLocation == null) {
continue;
}
double distance = gpsCalculationService.calculateDistance(
servicePointLocation,
record.delivery());
double distance =
gpsCalculationService.calculateDistance(
servicePointLocation, record.delivery());
if (distance < bestScore) {
bestScore = distance;
@ -298,28 +273,23 @@ public class PathFinderService {
}
}
if (bestDrone == null) {
throw new IllegalStateException(
"No available drone for delivery " + record.id());
throw new IllegalStateException("No available drone for delivery " + record.id());
}
return bestDrone;
}
/**
* Break a sequence of deliveries into several trips that each respect the
* drone move limit. The deliveries should already be ordered by proximity
* for sensible grouping.
* Break a sequence of deliveries into several trips that each respect the drone move limit. The
* deliveries should already be ordered by proximity for sensible grouping.
*
* @param deliveries Deliveries assigned to a drone.
* @param drone Drone that will service the deliveries.
* @param deliveries Deliveries assigned to a drone.
* @param drone Drone that will service the deliveries.
* @param servicePoint Starting and ending point of every trip.
* @return Partitioned trips with at least one delivery each.
* @throws IllegalStateException If a single delivery exceeds the drone's
* move limit.
* @throws IllegalStateException If a single delivery exceeds the drone's move limit.
*/
private List<List<MedDispatchRecRequest>> splitTrips(
List<MedDispatchRecRequest> deliveries,
Drone drone,
LngLat servicePoint) {
List<MedDispatchRecRequest> deliveries, Drone drone, LngLat servicePoint) {
List<List<MedDispatchRecRequest>> trips = new ArrayList<>();
List<MedDispatchRecRequest> currentTrip = new ArrayList<>();
for (MedDispatchRecRequest delivery : deliveries) {
@ -329,11 +299,11 @@ public class PathFinderService {
currentTrip.remove(currentTrip.size() - 1);
if (currentTrip.isEmpty()) {
throw new IllegalStateException(
"Delivery " +
delivery.id() +
" exceeds drone " +
drone.id() +
" move limit");
"Delivery "
+ delivery.id()
+ " exceeds drone "
+ drone.id()
+ " move limit");
}
trips.add(new ArrayList<>(currentTrip));
currentTrip.clear();
@ -352,20 +322,18 @@ public class PathFinderService {
}
/**
* Build a single trip for the provided drone, including the entire flight
* path to every delivery and back home. The resulting structure contains the
* {@link DronePath} representation as well as cost and moves consumed.
* Build a single trip for the provided drone, including the entire flight path to every
* delivery and back home. The resulting structure contains the {@link DronePath} representation
* 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 deliveries Deliveries to include in the trip in execution order.
* @param deliveries Deliveries to include in the trip in execution order.
* @return Trip information or {@code null} if no deliveries are provided.
* @see DeliveryPathResponse.DronePath
*/
private TripResult buildTrip(
Drone drone,
LngLat servicePoint,
List<MedDispatchRecRequest> deliveries) {
Drone drone, LngLat servicePoint, List<MedDispatchRecRequest> deliveries) {
if (deliveries == null || deliveries.isEmpty()) {
return null;
}
@ -390,9 +358,7 @@ public class PathFinderService {
moves += toDelivery.moves();
if (i == deliveries.size() - 1) {
PathSegment backHome = computePath(
delivery.delivery(),
servicePoint);
PathSegment backHome = computePath(delivery.delivery(), servicePoint);
backHome.appendSkippingStart(flightPath);
moves += backHome.moves();
current = servicePoint;
@ -402,9 +368,10 @@ public class PathFinderService {
flightPlans.add(new Delivery(delivery.id(), flightPath));
}
float cost = drone.capability().costInitial() +
drone.capability().costFinal() +
(float) (drone.capability().costPerMove() * moves);
float cost =
drone.capability().costInitial()
+ drone.capability().costFinal()
+ (float) (drone.capability().costPerMove() * moves);
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
* the path calculation without mutating any persistent state.
* Estimate the number of moves a prospective trip would need by replaying the path calculation
* without mutating any persistent state.
*
* @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.
*/
private int estimateTripMoves(
LngLat servicePoint,
List<MedDispatchRecRequest> deliveries) {
private int estimateTripMoves(LngLat servicePoint, List<MedDispatchRecRequest> deliveries) {
if (deliveries.isEmpty()) {
return 0;
}
@ -437,10 +402,10 @@ public class PathFinderService {
}
/**
* Build a path between {@code start} and {@code target} by repeatedly moving
* in snapped increments while avoiding restricted zones.
* Build a path between {@code start} and {@code target} by repeatedly moving in snapped
* increments while avoiding restricted zones.
*
* @param start Start coordinate.
* @param start Start coordinate.
* @param target Destination coordinate.
* @return Sequence of visited coordinates and move count.
* @see #nextPosition(LngLat, LngLat)
@ -453,8 +418,8 @@ public class PathFinderService {
positions.add(start);
LngLat current = start;
int iterations = 0;
while (!gpsCalculationService.isCloseTo(current, target) &&
iterations < MAX_SEGMENT_ITERATIONS) {
while (!gpsCalculationService.isCloseTo(current, target)
&& iterations < MAX_SEGMENT_ITERATIONS) {
LngLat next = nextPosition(current, target);
if (next.isSamePoint(current)) {
break;
@ -470,20 +435,18 @@ public class PathFinderService {
}
/**
* Determine the next position on the path from {@code current} toward
* {@code target}, preferring the snapped angle closest to the desired
* heading that does not infiltrate a restricted region.
* Determine the next position on the path from {@code current} toward {@code target},
* preferring the snapped angle closest to the desired heading that does not infiltrate a
* restricted region.
*
* @param current Current coordinate.
* @param target Destination coordinate.
* @return Next admissible coordinate or the original point if none can be
* found.
* @param target Destination coordinate.
* @return Next admissible coordinate or the original point if none can be found.
*/
private LngLat nextPosition(LngLat current, LngLat target) {
double desiredAngle = Math.toDegrees(
Math.atan2(
target.lat() - current.lat(),
target.lng() - current.lng()));
double desiredAngle =
Math.toDegrees(
Math.atan2(target.lat() - current.lat(), target.lng() - current.lng()));
List<Angle> candidateAngles = buildAngleCandidates(desiredAngle);
for (Angle angle : candidateAngles) {
LngLat next = gpsCalculationService.nextPosition(current, angle);
@ -495,12 +458,11 @@ public class PathFinderService {
}
/**
* Build a sequence of candidate angles centered on the desired heading,
* expanding symmetrically clockwise and counter-clockwise to explore
* alternative headings if the primary path is blocked.
* Build a sequence of candidate angles centered on the desired heading, expanding symmetrically
* clockwise and counter-clockwise to explore alternative headings if the primary path is
* blocked.
*
* @param desiredAngle Bearing in degrees between current and target
* positions.
* @param desiredAngle Bearing in degrees between current and target positions.
* @return Ordered list of candidate snapped angles.
* @see Angle#snap(double)
*/
@ -532,17 +494,16 @@ public class PathFinderService {
}
/**
* Representation of a computed path segment wrapping the visited positions
* and the number of moves taken to traverse them.
* Representation of a computed path segment wrapping the visited positions and the number of
* moves taken to traverse them.
*
* @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) {
/**
* Append the positions from this segment to {@code target}, skipping the
* first coordinate as it is already represented by the last coordinate
* in the consumer path.
* Append the positions from this segment to {@code target}, skipping the first coordinate
* as it is already represented by the last coordinate in the consumer path.
*
* @param target Mutable list to append to.
*/
@ -554,9 +515,8 @@ public class PathFinderService {
}
/**
* Bundle containing the calculated {@link DronePath}, total moves and
* financial cost for a single trip.
* Bundle containing the calculated {@link DronePath}, total moves and financial cost for a
* single trip.
*/
private record TripResult(DronePath path, int moves, float cost) {
}
private record TripResult(DronePath path, int moves, float cost) {}
}

View file

@ -1,22 +1,21 @@
package io.github.js0ny.ilp_coursework.util;
import java.math.BigDecimal;
import com.fasterxml.jackson.databind.JsonNode;
import java.math.BigDecimal;
/**
* 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 {
/**
* Helper for dynamic querying, to compare the json value with given value in
* {@code String}.
* Helper for dynamic querying, to compare the json value with given value in {@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 op The comparison operator
* @param op The comparison operator
* @return {@code true} if given values are equal, otherwise false.
*/
public static boolean isValueMatched(JsonNode node, String attrVal, AttrOperator op) {
@ -46,9 +45,9 @@ public class AttrComparator {
return switch (op) {
case EQ -> nodeVal.equals(attrVal);
default -> !nodeVal.equals(attrVal);
// case NE -> !nodeVal.equals(attrVal);
// case GT -> !nodeVal.equals(attrVal);// > 0;
// case LT -> !nodeVal.equals(attrVal);// < 0;
// case NE -> !nodeVal.equals(attrVal);
// case GT -> !nodeVal.equals(attrVal);// > 0;
// case LT -> !nodeVal.equals(attrVal);// < 0;
};
}
@ -56,9 +55,9 @@ public class AttrComparator {
return switch (op) {
case EQ -> nodeVal == attrVal;
default -> nodeVal != attrVal;
// case NE -> nodeVal != attrVal;
// case GT -> !nodeVal && attrVal; // false < true
// case LT -> nodeVal && !attrVal; // true > false
// case NE -> nodeVal != attrVal;
// case GT -> !nodeVal && attrVal; // false < true
// case LT -> nodeVal && !attrVal; // true > false
};
}
}

View file

@ -1,5 +1,10 @@
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.Test;
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.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
@AutoConfigureMockMvc
public class ActuatorHealthTest {
@Autowired
private MockMvc mockMvc;
@Autowired private MockMvc mockMvc;
@Test
@DisplayName("GET /actuator/health -> 200 OK")
void getActuator_shouldReturn200AndON() throws Exception {
String endpoint = "/actuator/health";
String expected = """
String expected =
"""
{
"status": "UP"
}

View file

@ -6,8 +6,6 @@ import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class IlpCourseworkApplicationTests {
@Test
void contextLoads() {
}
@Test
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 com.fasterxml.jackson.databind.ObjectMapper;
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.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.RegionCheckRequest;
import io.github.js0ny.ilp_coursework.service.GpsCalculationService;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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.result.MockMvcResultMatchers;
import java.util.List;
@WebMvcTest(ApiController.class)
public class ApiControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired private ObjectMapper objectMapper;
@MockitoBean
private GpsCalculationService service;
@MockitoBean private GpsCalculationService service;
@Nested
@DisplayName("GET /uid")
@ -59,21 +59,19 @@ public class ApiControllerTest {
@Test
@DisplayName("POST /distanceTo -> 200 OK")
void getDistance_shouldReturn200AndDistance_whenCorrectInput()
throws Exception {
void getDistance_shouldReturn200AndDistance_whenCorrectInput() throws Exception {
double expected = 5.0;
String endpoint = "/api/v1/distanceTo";
LngLat p1 = new LngLat(0, 4.0);
LngLat p2 = new LngLat(3.0, 0);
var req = new DistanceRequest(p1, p2);
when(
service.calculateDistance(any(LngLat.class), any(LngLat.class))
).thenReturn(expected);
var mock = mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))
);
when(service.calculateDistance(any(LngLat.class), any(LngLat.class)))
.thenReturn(expected);
var mock =
mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)));
mock.andExpect(status().isOk());
mock.andExpect(content().string(String.valueOf(expected)));
@ -83,43 +81,33 @@ public class ApiControllerTest {
@DisplayName("POST /distanceTo -> 400 Bad Request: Missing Field")
void getDistance_shouldReturn400_whenMissingField() throws Exception {
String endpoint = "/api/v1/distanceTo";
String req = """
{
"position1": {
"lng": 3.0,
"lat": 4.0
String req =
"""
{
"position1": {
"lng": 3.0,
"lat": 4.0
}
}
}
""";
when(
service.calculateDistance(any(LngLat.class), isNull())
).thenThrow(new NullPointerException());
mockMvc
.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(req)
)
.andExpect(status().isBadRequest());
""";
when(service.calculateDistance(any(LngLat.class), isNull()))
.thenThrow(new NullPointerException());
mockMvc.perform(post(endpoint).contentType(MediaType.APPLICATION_JSON).content(req))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("POST /distanceTo -> 400 Bad Request: Semantic errors")
void getDistance_shouldReturn400_whenInvalidInput() throws Exception {
String endpoint = "/api/v1/distanceTo";
String req = """
{ "position1": { "lng": -300.192473, "lat": 550.946233 }, "position2": { "lng": -3202.192473, "lat": 5533.942617 } }
""";
when(
service.calculateDistance(any(LngLat.class), isNull())
).thenThrow(new NullPointerException());
mockMvc
.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(req)
)
.andExpect(status().isBadRequest());
String req =
"""
{ "position1": { "lng": -300.192473, "lat": 550.946233 }, "position2": { "lng": -3202.192473, "lat": 5533.942617 } }
""";
when(service.calculateDistance(any(LngLat.class), isNull()))
.thenThrow(new NullPointerException());
mockMvc.perform(post(endpoint).contentType(MediaType.APPLICATION_JSON).content(req))
.andExpect(status().isBadRequest());
}
}
@ -129,21 +117,18 @@ public class ApiControllerTest {
@Test
@DisplayName("POST /isCloseTo -> 200 OK")
void getIsCloseTo_shouldReturn200AndBoolean_whenCorrectInput()
throws Exception {
void getIsCloseTo_shouldReturn200AndBoolean_whenCorrectInput() throws Exception {
boolean expected = false;
String endpoint = "/api/v1/isCloseTo";
LngLat p1 = new LngLat(0, 4.0);
LngLat p2 = new LngLat(3.0, 0);
var req = new DistanceRequest(p1, p2);
when(
service.isCloseTo(any(LngLat.class), any(LngLat.class))
).thenReturn(expected);
var mock = mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))
);
when(service.isCloseTo(any(LngLat.class), any(LngLat.class))).thenReturn(expected);
var mock =
mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)));
mock.andExpect(status().isOk());
mock.andExpect(content().string(String.valueOf(expected)));
@ -151,39 +136,32 @@ public class ApiControllerTest {
@Test
@DisplayName("POST /isCloseTo -> 400 Bad Request: Malformed JSON ")
void getIsCloseTo_shouldReturn400_whenJsonIsMalformed()
throws Exception {
void getIsCloseTo_shouldReturn400_whenJsonIsMalformed() throws Exception {
// json without a bracket
String malformedJson = """
{
"position1": { "lng": 0.0, "lat": 3.0 }
""";
mockMvc
.perform(
post("/api/v1/isCloseTo")
.contentType(MediaType.APPLICATION_JSON)
.content(malformedJson)
)
.andExpect(status().isBadRequest());
String malformedJson =
"""
{
"position1": { "lng": 0.0, "lat": 3.0 }
""";
mockMvc.perform(
post("/api/v1/isCloseTo")
.contentType(MediaType.APPLICATION_JSON)
.content(malformedJson))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("POST /isCloseTo -> 400 Bad Request: Semantic errors")
void getIsCloseTo_shouldReturn400_whenInvalidInput() throws Exception {
String endpoint = "/api/v1/isCloseTo";
String req = """
{ "position1": { "lng": -3004.192473, "lat": 550.946233 }, "position2": { "lng": -390.192473, "lat": 551.942617 } }
String req =
"""
{ "position1": { "lng": -3004.192473, "lat": 550.946233 }, "position2": { "lng": -390.192473, "lat": 551.942617 } }
""";
when(
service.calculateDistance(any(LngLat.class), isNull())
).thenThrow(new NullPointerException());
mockMvc
.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(req)
)
.andExpect(status().isBadRequest());
when(service.calculateDistance(any(LngLat.class), isNull()))
.thenThrow(new NullPointerException());
mockMvc.perform(post(endpoint).contentType(MediaType.APPLICATION_JSON).content(req))
.andExpect(status().isBadRequest());
}
}
@ -195,73 +173,59 @@ public class ApiControllerTest {
@Test
@DisplayName("POST /nextPosition -> 200 OK")
void getNextPosition_shouldReturn200AndCoordinate_whenCorrectInput()
throws Exception {
void getNextPosition_shouldReturn200AndCoordinate_whenCorrectInput() throws Exception {
LngLat expected = new LngLat(0.00015, 0.0);
LngLat p = new LngLat(0, 0);
var req = new MovementRequest(p, 0);
when(
service.nextPosition(any(LngLat.class), any(Angle.class))
).thenReturn(expected);
var mock = mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))
);
when(service.nextPosition(any(LngLat.class), any(Angle.class))).thenReturn(expected);
var mock =
mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)));
mock.andExpect(status().isOk());
mock.andExpect(
content().json(objectMapper.writeValueAsString(expected))
);
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("POST /nextPosition -> 400 Bad Request: Missing Field")
void getNextPosition_shouldReturn400_whenKeyNameError()
throws Exception {
void getNextPosition_shouldReturn400_whenKeyNameError() throws Exception {
// "position" should be "start"
String malformedJson = """
{
"position": { "lng": 0.0, "lat": 3.0 },
"angle": 180
}
""";
when(service.nextPosition(isNull(), any(Angle.class))).thenThrow(
new NullPointerException()
);
mockMvc
.perform(
post("/api/v1/nextPosition")
.contentType(MediaType.APPLICATION_JSON)
.content(malformedJson)
)
.andExpect(MockMvcResultMatchers.status().isBadRequest());
String malformedJson =
"""
{
"position": { "lng": 0.0, "lat": 3.0 },
"angle": 180
}
""";
when(service.nextPosition(isNull(), any(Angle.class)))
.thenThrow(new NullPointerException());
mockMvc.perform(
post("/api/v1/nextPosition")
.contentType(MediaType.APPLICATION_JSON)
.content(malformedJson))
.andExpect(MockMvcResultMatchers.status().isBadRequest());
}
@Test
@DisplayName("POST /nextPosition -> 400 Bad Request: Semantic errors")
void getNextPosition_shouldReturn400_whenInvalidInput()
throws Exception {
void getNextPosition_shouldReturn400_whenInvalidInput() throws Exception {
String endpoint = "/api/v1/nextPosition";
String req = """
{
"start": {
"lng": -3.192473,
"lat": 55.946233
},
"angle": 900
}
""";
when(
service.calculateDistance(any(LngLat.class), isNull())
).thenThrow(new NullPointerException());
mockMvc
.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(req)
)
.andExpect(status().isBadRequest());
String req =
"""
{
"start": {
"lng": -3.192473,
"lat": 55.946233
},
"angle": 900
}
""";
when(service.calculateDistance(any(LngLat.class), isNull()))
.thenThrow(new NullPointerException());
mockMvc.perform(post(endpoint).contentType(MediaType.APPLICATION_JSON).content(req))
.andExpect(status().isBadRequest());
}
}
@ -271,30 +235,27 @@ public class ApiControllerTest {
@Test
@DisplayName("POST /isInRegion -> 200 OK")
void getIsInRegion_shouldReturn200AndBoolean_whenCorrectInput()
throws Exception {
void getIsInRegion_shouldReturn200AndBoolean_whenCorrectInput() throws Exception {
boolean expected = false;
String endpoint = "/api/v1/isInRegion";
var position = new LngLat(1.234, 1.222);
var region = new Region(
"central",
List.of(
new LngLat(-3.192473, 55.946233),
new LngLat(-3.192473, 55.942617),
new LngLat(-3.184319, 55.942617),
new LngLat(-3.184319, 55.946233),
new LngLat(-3.192473, 55.946233)
)
);
var region =
new Region(
"central",
List.of(
new LngLat(-3.192473, 55.946233),
new LngLat(-3.192473, 55.942617),
new LngLat(-3.184319, 55.942617),
new LngLat(-3.184319, 55.946233),
new LngLat(-3.192473, 55.946233)));
var req = new RegionCheckRequest(position, region);
when(
service.checkIsInRegion(any(LngLat.class), any(Region.class))
).thenReturn(expected);
var mock = mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))
);
when(service.checkIsInRegion(any(LngLat.class), any(Region.class)))
.thenReturn(expected);
var mock =
mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)));
mock.andExpect(status().isOk());
mock.andExpect(content().string(String.valueOf(expected)));
@ -302,53 +263,44 @@ public class ApiControllerTest {
@Test
@DisplayName(
"POST /isInRegion -> 400 Bad Request: Passing a list of empty vertices to isInRegion"
)
void getIsInRegion_shouldReturn400_whenPassingIllegalArguments()
throws Exception {
"POST /isInRegion -> 400 Bad Request: Passing a list of empty vertices to"
+ " isInRegion")
void getIsInRegion_shouldReturn400_whenPassingIllegalArguments() throws Exception {
var position = new LngLat(1, 1);
var region = new Region("illegal", List.of());
var request = new RegionCheckRequest(position, region);
when(
service.checkIsInRegion(any(LngLat.class), any(Region.class))
).thenThrow(new IllegalArgumentException("Region is not closed."));
mockMvc
.perform(
post("/api/v1/isInRegion")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
.andExpect(status().isBadRequest());
when(service.checkIsInRegion(any(LngLat.class), any(Region.class)))
.thenThrow(new IllegalArgumentException("Region is not closed."));
mockMvc.perform(
post("/api/v1/isInRegion")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName(
"POST /isInRegion -> 400 Bad Request: Passing a list of not-closing vertices to isInRegion"
)
void getIsInRegion_shouldReturn400_whenPassingNotClosingVertices()
throws Exception {
"POST /isInRegion -> 400 Bad Request: Passing a list of not-closing vertices to"
+ " isInRegion")
void getIsInRegion_shouldReturn400_whenPassingNotClosingVertices() throws Exception {
var position = new LngLat(1, 1);
var region = new Region(
"illegal",
List.of(
new LngLat(1, 2),
new LngLat(3, 4),
new LngLat(5, 6),
new LngLat(7, 8),
new LngLat(9, 10)
)
);
var region =
new Region(
"illegal",
List.of(
new LngLat(1, 2),
new LngLat(3, 4),
new LngLat(5, 6),
new LngLat(7, 8),
new LngLat(9, 10)));
var request = new RegionCheckRequest(position, region);
when(
service.checkIsInRegion(any(LngLat.class), any(Region.class))
).thenThrow(new IllegalArgumentException("Region is not closed."));
mockMvc
.perform(
post("/api/v1/isInRegion")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
.andExpect(status().isBadRequest());
when(service.checkIsInRegion(any(LngLat.class), any(Region.class)))
.thenThrow(new IllegalArgumentException("Region is not closed."));
mockMvc.perform(
post("/api/v1/isInRegion")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.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.datatype.jsr310.JavaTimeModule;
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.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.DroneInfoService;
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.DisplayName;
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.web.servlet.MockMvc;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.Map;
@WebMvcTest(DroneController.class)
public class DroneControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired private MockMvc mockMvc;
private ObjectMapper objectMapper;
@MockitoBean
private DroneInfoService droneInfoService;
@MockitoBean private DroneInfoService droneInfoService;
@MockitoBean
private DroneAttrComparatorService droneAttrComparatorService;
@MockitoBean private DroneAttrComparatorService droneAttrComparatorService;
@MockitoBean
private PathFinderService pathFinderService;
@MockitoBean private PathFinderService pathFinderService;
@BeforeEach
void setUp() {
@ -66,14 +65,11 @@ public class DroneControllerTest {
throws Exception {
String endpoint = API_ENDPOINT_BASE + "true";
List<String> expected = List.of("1", "5", "8", "9");
when(
droneInfoService.dronesWithCooling(anyBoolean())).thenReturn(expected);
var mock = mockMvc.perform(
get(endpoint).contentType(MediaType.APPLICATION_JSON));
when(droneInfoService.dronesWithCooling(anyBoolean())).thenReturn(expected);
var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk());
mock.andExpect(
content().json(objectMapper.writeValueAsString(expected)));
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@ -82,23 +78,18 @@ public class DroneControllerTest {
throws Exception {
String endpoint = API_ENDPOINT_BASE + "false";
List<String> expected = List.of("2", "3", "4", "6", "7", "10");
when(
droneInfoService.dronesWithCooling(anyBoolean())).thenReturn(expected);
var mock = mockMvc.perform(
get(endpoint).contentType(MediaType.APPLICATION_JSON));
when(droneInfoService.dronesWithCooling(anyBoolean())).thenReturn(expected);
var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk());
mock.andExpect(
content().json(objectMapper.writeValueAsString(expected)));
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("-> 400 Bad Request")
void getDronesWithCooling_shouldReturn400_whenStateIsInvalid()
throws Exception {
void getDronesWithCooling_shouldReturn400_whenStateIsInvalid() throws Exception {
String endpoint = API_ENDPOINT_BASE + "invalid";
mockMvc.perform(
get(endpoint).contentType(MediaType.APPLICATION_JSON))
mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest());
}
}
@ -111,33 +102,29 @@ public class DroneControllerTest {
@Test
@DisplayName("-> 200 OK")
void getDroneDetails_shouldReturn200AndJson_whenCorrectInput()
throws Exception {
Drone expected = new Drone("Drone 1", "1",
new DroneCapability(true, true, 4.0f, 2000, 0.01f, 4.3f, 6.5f));
void getDroneDetails_shouldReturn200AndJson_whenCorrectInput() throws Exception {
Drone expected =
new Drone(
"Drone 1",
"1",
new DroneCapability(true, true, 4.0f, 2000, 0.01f, 4.3f, 6.5f));
String endpoint = API_ENDPOINT_BASE + "1";
when(
droneInfoService.droneDetail(anyString())).thenReturn(expected);
var mock = mockMvc.perform(
get(endpoint).contentType(MediaType.APPLICATION_JSON));
when(droneInfoService.droneDetail(anyString())).thenReturn(expected);
var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk());
mock.andExpect(
content().json(objectMapper.writeValueAsString(expected)));
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("-> 404 Not Found")
void getDroneDetails_shouldReturn404_whenDroneNotFound()
throws Exception {
void getDroneDetails_shouldReturn404_whenDroneNotFound() throws Exception {
String endpoint = API_ENDPOINT_BASE + "invalidDroneId";
when(
droneInfoService.droneDetail(anyString())).thenThrow(new IllegalArgumentException());
mockMvc.perform(
get(endpoint).contentType(MediaType.APPLICATION_JSON))
when(droneInfoService.droneDetail(anyString()))
.thenThrow(new IllegalArgumentException());
mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
}
@Nested
@ -148,64 +135,64 @@ public class DroneControllerTest {
@Test
@DisplayName("capacity = 8 -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenCapacityIs8()
throws Exception {
void getQueryAsPath_shouldReturn200AndArrayOfString_whenCapacityIs8() throws Exception {
String attrName = "capacity";
String attrVal = "8";
List<String> expected = List.of("2", "4", "7", "9");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected);
mockMvc.perform(get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("heating = true -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenHeatingIsTrue()
throws Exception {
void getQueryAsPath_shouldReturn200AndArrayOfString_whenHeatingIsTrue() throws Exception {
String attrName = "heating";
String attrVal = "true";
List<String> expected = List.of("1", "2", "4", "5", "6", "7", "9");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected);
mockMvc.perform(get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("cooling = false -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenCoolingIsFalse()
throws Exception {
void getQueryAsPath_shouldReturn200AndArrayOfString_whenCoolingIsFalse() throws Exception {
String attrName = "cooling";
String attrVal = "false";
List<String> expected = List.of("2", "3", "4", "6", "7", "10");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected);
mockMvc.perform(get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("maxMoves = 1000 -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenMaxMovesIs1000()
throws Exception {
void getQueryAsPath_shouldReturn200AndArrayOfString_whenMaxMovesIs1000() throws Exception {
String attrName = "maxMoves";
String attrVal = "1000";
List<String> expected = List.of("2", "4", "7", "9");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected);
mockMvc.perform(get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@ -220,8 +207,9 @@ public class DroneControllerTest {
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected);
mockMvc.perform(get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@ -243,31 +231,34 @@ public class DroneControllerTest {
AttrQueryRequest[] requestBody = {req1, req2, req3};
List<String> expected = List.of("8");
when(droneAttrComparatorService.dronesSatisfyingAttributes(any(AttrQueryRequest[].class)))
when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("GT LT -> 200 OK")
void postQuery_shouldReturn200AndArrayOfString_whenGtLtConditions()
throws Exception {
void postQuery_shouldReturn200AndArrayOfString_whenGtLtConditions() throws Exception {
AttrQueryRequest req1 = new AttrQueryRequest("capacity", ">", "8");
AttrQueryRequest req2 = new AttrQueryRequest("maxMoves", "<", "2000");
AttrQueryRequest[] requestBody = {req1, req2};
List<String> expected = List.of("5", "10");
when(droneAttrComparatorService.dronesSatisfyingAttributes(any(AttrQueryRequest[].class)))
when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@ -281,32 +272,35 @@ public class DroneControllerTest {
AttrQueryRequest[] requestBody = {req1, req2};
List<String> expected = List.of();
when(droneAttrComparatorService.dronesSatisfyingAttributes(any(AttrQueryRequest[].class)))
when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("GT LT EQ -> 200 OK")
void postQuery_shouldReturn200AndArrayOfString_whenGtLtEqConditions()
throws Exception {
void postQuery_shouldReturn200AndArrayOfString_whenGtLtEqConditions() throws Exception {
AttrQueryRequest req1 = new AttrQueryRequest("capacity", ">", "8");
AttrQueryRequest req2 = new AttrQueryRequest("maxMoves", "<", "2000");
AttrQueryRequest req3 = new AttrQueryRequest("cooling", "=", "true");
AttrQueryRequest[] requestBody = {req1, req2, req3};
List<String> expected = List.of("5");
when(droneAttrComparatorService.dronesSatisfyingAttributes(any(AttrQueryRequest[].class)))
when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@ -324,16 +318,23 @@ public class DroneControllerTest {
throws Exception {
var reqs = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
var delivery = new LngLat(-3.00, 55.121);
var record = new MedDispatchRecRequest(123, LocalDate.parse("2025-12-22"), LocalTime.parse("14:30"), reqs, delivery);
var record =
new MedDispatchRecRequest(
123,
LocalDate.parse("2025-12-22"),
LocalTime.parse("14:30"),
reqs,
delivery);
MedDispatchRecRequest[] requestBody = {record};
List<String> expected = List.of("1", "2", "6", "7", "9");
when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class)))
.thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@ -342,24 +343,30 @@ public class DroneControllerTest {
@DisplayName("Treat Null as False (Cooling) -> 200 OK")
void postQueryAvailableDrones_shouldReturn200AndArrayOfString_whenCoolingIsNull()
throws Exception {
var requestMap = Map.of(
"id", 123,
"date", "2025-12-22",
"time", "14:30",
"requirements", Map.of(
"capacity", 0.75,
"heating", true,
"maxCost", 13.5
)
);
var requestMap =
Map.of(
"id",
123,
"date",
"2025-12-22",
"time",
"14:30",
"requirements",
Map.of(
"capacity", 0.75,
"heating", true,
"maxCost", 13.5));
List<String> expected = List.of("1", "2", "6", "7", "9");
when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class)))
.thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new Object[]{requestMap})))
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(
objectMapper.writeValueAsString(
new Object[] {requestMap})))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@ -370,11 +377,23 @@ public class DroneControllerTest {
throws Exception {
var reqs1 = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
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 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};
List<String> expected = List.of("2", "7", "9");
@ -382,11 +401,12 @@ public class DroneControllerTest {
when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class)))
.thenReturn(expected);
mockMvc.perform(post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
}
}
}

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.ServicePointDrones;
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.DisplayName;
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.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)
public class DroneInfoServiceTest {
@Mock
private RestTemplate restTemplate;
@Mock private RestTemplate restTemplate;
private DroneInfoService droneInfoService;
@ -44,9 +45,9 @@ public class DroneInfoServiceTest {
private Drone[] getMockDrones() {
return new Drone[] {
new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)),
new Drone("Drone 2", "2", new DroneCapability(false, true, 20, 2000, 2, 2, 2)),
new Drone("Drone 3", "3", new DroneCapability(false, false, 30, 3000, 3, 3, 3))
new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)),
new Drone("Drone 2", "2", new DroneCapability(false, true, 20, 2000, 2, 2, 2)),
new Drone("Drone 3", "3", new DroneCapability(false, false, 30, 3000, 3, 3, 3))
};
}
@ -134,30 +135,46 @@ public class DroneInfoServiceTest {
@DisplayName("dronesMatchesRequirements(MedDispatchRecRequest[]) tests")
class DronesMatchesRequirementsTests {
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);
ServicePointDrones spd = new ServicePointDrones(1, new DroneAvailability[] { drone1Avail });
return new ServicePointDrones[] { spd };
ServicePointDrones spd =
new ServicePointDrones(1, new DroneAvailability[] {drone1Avail});
return new ServicePointDrones[] {spd};
}
@Test
@DisplayName("Should return drones matching a single requirement")
void dronesMatchesRequirements_shouldReturnMatchingDrones_forSingleRequirement() {
// Arrange
var drones = new Drone[] {
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-for-service-points"),
ServicePointDrones[].class)).thenReturn(getMockServicePointDrones());
var drones =
new Drone[] {
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-for-service-points"),
ServicePointDrones[].class))
.thenReturn(getMockServicePointDrones());
var requirement = new MedDispatchRecRequest.MedRequirement(8, true, false, 100);
var record = new MedDispatchRecRequest(1, LocalDate.now().with(DayOfWeek.MONDAY), LocalTime.of(10, 0),
requirement, new LngLat(0, 0));
var record =
new MedDispatchRecRequest(
1,
LocalDate.now().with(DayOfWeek.MONDAY),
LocalTime.of(10, 0),
requirement,
new LngLat(0, 0));
// Act
List<String> result = droneInfoService.dronesMatchesRequirements(new MedDispatchRecRequest[] { record });
List<String> result =
droneInfoService.dronesMatchesRequirements(
new MedDispatchRecRequest[] {record});
// Assert
assertThat(result).containsExactly("1");
@ -167,19 +184,30 @@ public class DroneInfoServiceTest {
@DisplayName("Should return empty list if no drones match")
void dronesMatchesRequirements_shouldReturnEmptyList_whenNoDronesMatch() {
// Arrange
var drones = new Drone[] {
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);
var drones =
new Drone[] {
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);
// No need to mock drones-for-service-points as it won't be called
var requirement = new MedDispatchRecRequest.MedRequirement(10, true, false, 100);
var record = new MedDispatchRecRequest(1, LocalDate.now().with(DayOfWeek.MONDAY), LocalTime.of(10, 0),
requirement, new LngLat(0, 0));
var record =
new MedDispatchRecRequest(
1,
LocalDate.now().with(DayOfWeek.MONDAY),
LocalTime.of(10, 0),
requirement,
new LngLat(0, 0));
// Act
List<String> result = droneInfoService.dronesMatchesRequirements(new MedDispatchRecRequest[] { record });
List<String> result =
droneInfoService.dronesMatchesRequirements(
new MedDispatchRecRequest[] {record});
// Assert
assertThat(result).isEmpty();
@ -194,7 +222,8 @@ public class DroneInfoServiceTest {
// Act
List<String> resultNull = droneInfoService.dronesMatchesRequirements(null);
List<String> resultEmpty = droneInfoService.dronesMatchesRequirements(new MedDispatchRecRequest[0]);
List<String> resultEmpty =
droneInfoService.dronesMatchesRequirements(new MedDispatchRecRequest[0]);
// Assert
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.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.List;
public class GpsCalculationServiceTest {
private static final double STEP = 0.00015;
@ -114,9 +116,7 @@ public class GpsCalculationServiceTest {
}
@Test
@DisplayName(
"True: Two points are close to each other and near threshold"
)
@DisplayName("True: Two points are close to each other and near threshold")
void isCloseTo_shouldReturnTrue_whenCloseAndSmallerThanThreshold() {
var p1 = new LngLat(0.0, 0.0);
var p2 = new LngLat(0.0, 0.00014);
@ -160,20 +160,12 @@ public class GpsCalculationServiceTest {
var actual = service.nextPosition(start, angle);
assertThat(actual.lng()).isCloseTo(
expected.lng(),
within(PRECISION)
);
assertThat(actual.lat()).isCloseTo(
expected.lat(),
within(PRECISION)
);
assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
}
@Test
@DisplayName(
"Cardinal Direction: nextPosition in North direction (90 degrees)"
)
@DisplayName("Cardinal Direction: nextPosition in North direction (90 degrees)")
void nextPosition_shouldMoveNorth_forAngle90() {
var start = new LngLat(0.0, 0.0);
Angle angle = new Angle(90);
@ -182,20 +174,12 @@ public class GpsCalculationServiceTest {
var actual = service.nextPosition(start, angle);
assertThat(actual.lng()).isCloseTo(
expected.lng(),
within(PRECISION)
);
assertThat(actual.lat()).isCloseTo(
expected.lat(),
within(PRECISION)
);
assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
}
@Test
@DisplayName(
"Cardinal Direction: nextPosition in West direction (180 degrees)"
)
@DisplayName("Cardinal Direction: nextPosition in West direction (180 degrees)")
void nextPosition_shouldMoveWest_forAngle180() {
var start = new LngLat(0.0, 0.0);
Angle angle = new Angle(180);
@ -205,20 +189,12 @@ public class GpsCalculationServiceTest {
var actual = service.nextPosition(start, angle);
assertThat(actual.lng()).isCloseTo(
expected.lng(),
within(PRECISION)
);
assertThat(actual.lat()).isCloseTo(
expected.lat(),
within(PRECISION)
);
assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
}
@Test
@DisplayName(
"Cardinal Direction: nextPosition in South direction (270 degrees)"
)
@DisplayName("Cardinal Direction: nextPosition in South direction (270 degrees)")
void nextPosition_shouldMoveSouth_forAngle270() {
var start = new LngLat(0.0, 0.0);
Angle angle = new Angle(270);
@ -228,20 +204,12 @@ public class GpsCalculationServiceTest {
var actual = service.nextPosition(start, angle);
assertThat(actual.lng()).isCloseTo(
expected.lng(),
within(PRECISION)
);
assertThat(actual.lat()).isCloseTo(
expected.lat(),
within(PRECISION)
);
assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
}
@Test
@DisplayName(
"Intercardinal Direction: nextPosition in Northeast direction (45 degrees)"
)
@DisplayName("Intercardinal Direction: nextPosition in Northeast direction (45 degrees)")
void nextPosition_shouldMoveNortheast_forAngle45() {
var start = new LngLat(0.0, 0.0);
Angle angle = new Angle(45);
@ -252,47 +220,37 @@ public class GpsCalculationServiceTest {
var actual = service.nextPosition(start, angle);
assertThat(actual.lng()).isCloseTo(
expected.lng(),
within(PRECISION)
);
assertThat(actual.lat()).isCloseTo(
expected.lat(),
within(PRECISION)
);
assertThat(actual.lng()).isCloseTo(expected.lng(), within(PRECISION));
assertThat(actual.lat()).isCloseTo(expected.lat(), within(PRECISION));
}
@Nested
@DisplayName(
"Test for checkIsInRegion(LngLatDto, RegionDto) -> boolean"
)
@DisplayName("Test for checkIsInRegion(LngLatDto, RegionDto) -> boolean")
class CheckIsInRegionTests {
public static final Region RECTANGLE_REGION = new Region(
"rectangle",
List.of(
new LngLat(0.0, 0.0),
new LngLat(2.0, 0.0),
new LngLat(2.0, 2.0),
new LngLat(0.0, 2.0),
new LngLat(0.0, 0.0)
)
);
public static final Region RECTANGLE_REGION =
new Region(
"rectangle",
List.of(
new LngLat(0.0, 0.0),
new LngLat(2.0, 0.0),
new LngLat(2.0, 2.0),
new LngLat(0.0, 2.0),
new LngLat(0.0, 0.0)));
@Test
@DisplayName("General Case: Given Example for Testing")
void isInRegion_shouldReturnFalse_givenPolygonCentral() {
var position = new LngLat(1.234, 1.222);
var region = new Region(
"central",
List.of(
new LngLat(-3.192473, 55.946233),
new LngLat(-3.192473, 55.942617),
new LngLat(-3.184319, 55.942617),
new LngLat(-3.184319, 55.946233),
new LngLat(-3.192473, 55.946233)
)
);
var region =
new Region(
"central",
List.of(
new LngLat(-3.192473, 55.946233),
new LngLat(-3.192473, 55.942617),
new LngLat(-3.184319, 55.942617),
new LngLat(-3.184319, 55.946233),
new LngLat(-3.192473, 55.946233)));
boolean expected = false;
boolean actual = service.checkIsInRegion(position, region);
assertThat(actual).isEqualTo(expected);
@ -303,10 +261,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_forSimpleRectangle() {
var position = new LngLat(1.0, 1.0);
boolean expected = true;
boolean actual = service.checkIsInRegion(
position,
RECTANGLE_REGION
);
boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
assertThat(actual).isEqualTo(expected);
}
@ -315,10 +270,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnFalse_forSimpleRectangle() {
var position = new LngLat(3.0, 1.0);
boolean expected = false;
boolean actual = service.checkIsInRegion(
position,
RECTANGLE_REGION
);
boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
assertThat(actual).isEqualTo(expected);
}
@ -326,18 +278,17 @@ public class GpsCalculationServiceTest {
@DisplayName("General Case: Simple Hexagon")
void isInRegion_shouldReturnTrue_forSimpleHexagon() {
var position = new LngLat(2.0, 2.0);
var region = new Region(
"hexagon",
List.of(
new LngLat(1.0, 0.0),
new LngLat(4.0, 0.0),
new LngLat(5.0, 2.0),
new LngLat(4.0, 4.0),
new LngLat(1.0, 4.0),
new LngLat(0.0, 2.0),
new LngLat(1.0, 0.0)
)
);
var region =
new Region(
"hexagon",
List.of(
new LngLat(1.0, 0.0),
new LngLat(4.0, 0.0),
new LngLat(5.0, 2.0),
new LngLat(4.0, 4.0),
new LngLat(1.0, 4.0),
new LngLat(0.0, 2.0),
new LngLat(1.0, 0.0)));
boolean expected = true;
boolean actual = service.checkIsInRegion(position, region);
assertThat(actual).isEqualTo(expected);
@ -347,15 +298,14 @@ public class GpsCalculationServiceTest {
@DisplayName("Edge Case: Small Triangle")
void isInRegion_shouldReturnTrue_forSmallTriangle() {
var position = new LngLat(0.00001, 0.00001);
var region = new Region(
"triangle",
List.of(
new LngLat(0.0, 0.0),
new LngLat(0.0001, 0.0),
new LngLat(0.00005, 0.0001),
new LngLat(0.0, 0.0)
)
);
var region =
new Region(
"triangle",
List.of(
new LngLat(0.0, 0.0),
new LngLat(0.0001, 0.0),
new LngLat(0.00005, 0.0001),
new LngLat(0.0, 0.0)));
boolean expected = true;
boolean actual = service.checkIsInRegion(position, region);
assertThat(actual).isEqualTo(expected);
@ -366,10 +316,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_whenPointOnLowerEdge() {
var position = new LngLat(0.0, 1.0);
boolean expected = true;
boolean actual = service.checkIsInRegion(
position,
RECTANGLE_REGION
);
boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
assertThat(actual).isEqualTo(expected);
}
@ -378,10 +325,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_whenPointOnUpperEdge() {
var position = new LngLat(2.0, 1.0);
boolean expected = true;
boolean actual = service.checkIsInRegion(
position,
RECTANGLE_REGION
);
boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
assertThat(actual).isEqualTo(expected);
}
@ -390,10 +334,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_whenPointOnLeftEdge() {
var position = new LngLat(0.0, 1.0);
boolean expected = true;
boolean actual = service.checkIsInRegion(
position,
RECTANGLE_REGION
);
boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
assertThat(actual).isEqualTo(expected);
}
@ -402,10 +343,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_whenPointOnLowerVertex() {
var position = new LngLat(0.0, 0.0);
boolean expected = true;
boolean actual = service.checkIsInRegion(
position,
RECTANGLE_REGION
);
boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
assertThat(actual).isEqualTo(expected);
}
@ -414,10 +352,7 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldReturnTrue_whenPointOnUpperVertex() {
var position = new LngLat(2.0, 2.0);
boolean expected = true;
boolean actual = service.checkIsInRegion(
position,
RECTANGLE_REGION
);
boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION);
assertThat(actual).isEqualTo(expected);
}
@ -425,40 +360,40 @@ public class GpsCalculationServiceTest {
@DisplayName("Edge Case: Region not forming polygon")
void isInRegion_shouldThrowExceptions_whenRegionNotFormingPolygon() {
var position = new LngLat(2.0, 2.0);
var region = new Region(
"line",
List.of(
new LngLat(0.0, 0.0),
new LngLat(0.0001, 0.0),
new LngLat(0.0, 0.0)
)
);
assertThatThrownBy(() -> {
service.checkIsInRegion(position, region);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Region is not closed.");
var region =
new Region(
"line",
List.of(
new LngLat(0.0, 0.0),
new LngLat(0.0001, 0.0),
new LngLat(0.0, 0.0)));
assertThatThrownBy(
() -> {
service.checkIsInRegion(position, region);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Region is not closed.");
}
@Test
@DisplayName("Edge Case: Region is not closed")
void isInRegion_shouldThrowExceptions_whenRegionNotClose() {
var position = new LngLat(2.0, 2.0);
var region = new Region(
"rectangle",
List.of(
new LngLat(0.0, 0.0),
new LngLat(2.0, 0.0),
new LngLat(2.0, 2.0),
new LngLat(0.0, 2.0),
new LngLat(0.0, -1.0)
)
);
assertThatThrownBy(() -> {
service.checkIsInRegion(position, region);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Region is not closed.");
var region =
new Region(
"rectangle",
List.of(
new LngLat(0.0, 0.0),
new LngLat(2.0, 0.0),
new LngLat(2.0, 2.0),
new LngLat(0.0, 2.0),
new LngLat(0.0, -1.0)));
assertThatThrownBy(
() -> {
service.checkIsInRegion(position, region);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Region is not closed.");
}
@Test
@ -466,11 +401,12 @@ public class GpsCalculationServiceTest {
void isInRegion_shouldThrowExceptions_whenListIsEmpty() {
var position = new LngLat(2.0, 2.0);
var region = new Region("rectangle", List.of());
assertThatThrownBy(() -> {
service.checkIsInRegion(position, region);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Region is not closed.");
assertThatThrownBy(
() -> {
service.checkIsInRegion(position, region);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Region is not closed.");
}
}
}

View file

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