diff --git a/src/main/java/io/github/js0ny/ilp_coursework/controller/DroneController.java b/src/main/java/io/github/js0ny/ilp_coursework/controller/DroneController.java index ee23eea..541a92d 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/controller/DroneController.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/controller/DroneController.java @@ -6,6 +6,7 @@ import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest; import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse; import io.github.js0ny.ilp_coursework.service.DroneAttrComparatorService; import io.github.js0ny.ilp_coursework.service.DroneInfoService; +import io.github.js0ny.ilp_coursework.service.PathFinderService; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -24,6 +25,7 @@ public class DroneController { private final DroneInfoService droneInfoService; private final DroneAttrComparatorService droneAttrComparatorService; + private final PathFinderService pathFinderService; /** * Constructor of the {@code DroneController} with the business logic dependency @@ -37,10 +39,12 @@ public class DroneController { */ public DroneController( DroneInfoService droneService, - DroneAttrComparatorService droneAttrComparatorService + DroneAttrComparatorService droneAttrComparatorService, + PathFinderService pathFinderService ) { this.droneInfoService = droneService; this.droneAttrComparatorService = droneAttrComparatorService; + this.pathFinderService = pathFinderService; } /** @@ -114,14 +118,13 @@ public class DroneController { public DeliveryPathResponse calculateDeliveryPath( @RequestBody MedDispatchRecRequest[] record ) { - // return new DeliveryPathResponse(0.0f, 0, new DronePathDto[] {}); - return null; + return pathFinderService.calculateDeliveryPath(record); } @PostMapping("/calcDeliveryPathAsGeoJson") public String calculateDeliveryPathAsGeoJson( @RequestBody MedDispatchRecRequest[] record ) { - return "{}"; + return pathFinderService.calculateDeliveryPathAsGeoJson(record); } } diff --git a/src/main/java/io/github/js0ny/ilp_coursework/controller/GeoJsonDataController.java b/src/main/java/io/github/js0ny/ilp_coursework/controller/GeoJsonDataController.java index f637e55..a372ccb 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/controller/GeoJsonDataController.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/controller/GeoJsonDataController.java @@ -1,4 +1,26 @@ 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 { + + private final DroneInfoService droneInfoService; + + public GeoJsonDataController(DroneInfoService droneInfoService) { + this.droneInfoService = droneInfoService; + } + + @GetMapping("/getRestrictedAreaByGeoJson") + public String getRestrictedAreaGeoJson() throws JsonProcessingException { + return droneInfoService.fetchRestrictedAreasInGeoJson().stream().reduce("", String::concat); + } } diff --git a/src/main/java/io/github/js0ny/ilp_coursework/service/PathFinderService.java b/src/main/java/io/github/js0ny/ilp_coursework/service/PathFinderService.java index 9f84f5f..0d26328 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/service/PathFinderService.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/service/PathFinderService.java @@ -1,143 +1,562 @@ package io.github.js0ny.ilp_coursework.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import io.github.js0ny.ilp_coursework.controller.DroneController; +import io.github.js0ny.ilp_coursework.data.common.Angle; +import io.github.js0ny.ilp_coursework.data.common.DroneAvailability; import io.github.js0ny.ilp_coursework.data.common.LngLat; import io.github.js0ny.ilp_coursework.data.common.Region; +import io.github.js0ny.ilp_coursework.data.external.Drone; import io.github.js0ny.ilp_coursework.data.external.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 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 java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.LinkedList; -import java.util.Map; -import java.util.PriorityQueue; -import java.util.Set; - +/** + * Class that handles calculations about deliverypath + * + * @see DroneInfoService + * @see DroneController + * @see DeliveryPathResponse + */ @Service public class PathFinderService { - private final GpsCalculationService service; + /** + * 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) + */ + private static final int MAX_SEGMENT_ITERATIONS = 8_000; - public PathFinderService(GpsCalculationService gpsCalculationService) { - this.service = gpsCalculationService; - } + // Services + private final GpsCalculationService gpsCalculationService; + private final DroneInfoService droneInfoService; + private final ObjectMapper objectMapper; + private final List drones; + private final Map droneById; + private final Map droneServicePointMap; + private final Map servicePointLocations; + private final List restrictedRegions; + /** + * 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. + */ + public PathFinderService( + GpsCalculationService gpsCalculationService, + DroneInfoService droneInfoService) { + this.gpsCalculationService = gpsCalculationService; + this.droneInfoService = droneInfoService; + this.objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - private static class Node implements Comparable { + this.drones = droneInfoService.fetchAllDrones(); + List servicePoints = droneInfoService.fetchServicePoints(); + List servicePointAssignments = droneInfoService.fetchDronesForServicePoints(); + List restrictedAreas = droneInfoService.fetchRestrictedAreas(); - final LngLat point; - Node parent; + this.droneById = this.drones.stream().collect( + Collectors.toMap(Drone::id, drone -> drone)); - double g; - double h; - double f; - - public Node(LngLat point, Node parent, double g, double h) { - this.point = point; - this.parent = parent; - this.g = g; - this.h = h; - this.f = g + h; - } - - @Override - public int compareTo(Node other) { - return Double.compare(this.f, other.f); - } - } - - - public static List findPath( - LngLat start, - LngLat target, - List restricted - ) { - var service = new GpsCalculationService(); - PriorityQueue openSet = new PriorityQueue<>(); - Map allNodesMinG = new HashMap<>(); - - if (checkIsInRestrictedAreas(target, restricted)) { - return Collections.emptyList(); - } - - Node startNode = new Node( - start, - null, - 0, - service.calculateDistance(start, target) - ); - openSet.add(startNode); - allNodesMinG.put(start, 0.0); - - while (!openSet.isEmpty()) { - Node current = openSet.poll(); - - if (service.isCloseTo(current.point, target)) { - return reconstructPath(current); + this.droneServicePointMap = new HashMap<>(); + for (ServicePointDrones assignment : servicePointAssignments) { + if (assignment == null || assignment.drones() == null) { + continue; } + for (DroneAvailability availability : assignment.drones()) { + if (availability == null || availability.id() == null) { + continue; + } + droneServicePointMap.put( + availability.id(), + assignment.servicePointId()); + } + } - if ( - current.g > - allNodesMinG.getOrDefault(current.point, Double.MAX_VALUE) - ) { + this.servicePointLocations = servicePoints + .stream() + .collect( + Collectors.toMap(ServicePoint::id, sp -> new LngLat(sp.location()))); + + 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. + * + * @param records Dispatch records to be fulfilled. + * @return Aggregated path response with cost and move totals. + * @see #calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[]) + */ + public DeliveryPathResponse calculateDeliveryPath( + MedDispatchRecRequest[] records) { + if (records == null || records.length == 0) { + return new DeliveryPathResponse(0f, 0, new DronePath[0]); + } + + Map> assigned = assignDeliveries( + records); + + List paths = new ArrayList<>(); + float totalCost = 0f; + int totalMoves = 0; + + for (Map.Entry> entry : assigned.entrySet()) { + String droneId = entry.getKey(); + Drone drone = droneById.get(droneId); + if (drone == null) { + continue; + } + Integer spId = droneServicePointMap.get(droneId); + if (spId == null) { + continue; + } + LngLat servicePointLocation = servicePointLocations.get(spId); + if (servicePointLocation == null) { continue; } - for (LngLat neighbour : getNeighbours(current.point)) { - if (checkIsInRestrictedAreas(neighbour, restricted)) { - continue; - } + List sortedDeliveries = entry + .getValue() + .stream() + .sorted( + Comparator.comparingDouble(rec -> gpsCalculationService.calculateDistance( + servicePointLocation, + rec.delivery()))) + .toList(); - double newG = current.g + 0.00015; + List> trips = splitTrips( + sortedDeliveries, + drone, + servicePointLocation); - if (newG < allNodesMinG.getOrDefault(neighbour, Double.MAX_VALUE)) { - double newH = service.calculateDistance(neighbour, target); - Node neighbourNode = new Node(neighbour, current, newG, newH); - allNodesMinG.put(neighbour, newG); - openSet.add(neighbourNode); + for (List trip : trips) { + TripResult result = buildTrip( + drone, + servicePointLocation, + trip); + if (result != null) { + totalCost += result.cost(); + totalMoves += result.moves(); + paths.add(result.path()); } } } - return Collections.emptyList(); + + return new DeliveryPathResponse( + totalCost, + totalMoves, + paths.toArray(new DronePath[0])); } - private static List reconstructPath(Node endNode) { - LinkedList path = new LinkedList<>(); - Node curr = endNode; - while (curr != null) { - path.addFirst(curr.point); - curr = curr.parent; + /** + * 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) { + DeliveryPathResponse response = calculateDeliveryPath(records); + Map featureCollection = new LinkedHashMap<>(); + featureCollection.put("type", "FeatureCollection"); + List> features = new ArrayList<>(); + + if (response != null && response.dronePaths() != null) { + for (DronePath dronePath : response.dronePaths()) { + if (dronePath == null || dronePath.deliveries() == null) { + continue; + } + for (Delivery delivery : dronePath.deliveries()) { + Map feature = new LinkedHashMap<>(); + feature.put("type", "Feature"); + + Map properties = new LinkedHashMap<>(); + properties.put("droneId", dronePath.droneId()); + properties.put("deliveryId", delivery.deliveryId()); + feature.put("properties", properties); + + Map geometry = new LinkedHashMap<>(); + geometry.put("type", "LineString"); + + List> coordinates = new ArrayList<>(); + if (delivery.flightPath() != null) { + for (LngLat point : delivery.flightPath()) { + coordinates.add(List.of(point.lng(), point.lat())); + } + } + geometry.put("coordinates", coordinates); + feature.put("geometry", geometry); + features.add(feature); + } + } + } + + featureCollection.put("features", features); + + try { + return objectMapper.writeValueAsString(featureCollection); + } catch (JsonProcessingException e) { + throw new IllegalStateException( + "Failed to generate GeoJSON payload", + e); } - return path; } - private static boolean checkIsInRestrictedAreas( - LngLat point, - List RestrictedAreas - ) { - var service = new GpsCalculationService(); - for (RestrictedArea area : RestrictedAreas) { - Region r = area.toRegion(); - if (service.checkIsInRegion(point, r)) { + /** + * 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. + */ + private Map> assignDeliveries( + MedDispatchRecRequest[] records) { + Map> assignments = new LinkedHashMap<>(); + for (MedDispatchRecRequest record : records) { + if (record == null || record.delivery() == null) { + continue; + } + String droneId = findBestDrone(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. + * + * @param record Dispatch record that needs fulfillment. + * @return Identifier of the drone that should fly the mission. + * @throws IllegalStateException If no available drone can serve the request. + */ + private String findBestDrone(MedDispatchRecRequest record) { + double bestScore = Double.MAX_VALUE; + String bestDrone = null; + + for (Drone drone : drones) { + if (!droneInfoService.droneMatchesRequirement(drone, record)) { + continue; + } + String droneId = drone.id(); + Integer servicePointId = droneServicePointMap.get(droneId); + if (servicePointId == null) { + continue; + } + LngLat servicePointLocation = servicePointLocations.get( + servicePointId); + if (servicePointLocation == null) { + continue; + } + + double distance = gpsCalculationService.calculateDistance( + servicePointLocation, + record.delivery()); + + if (distance < bestScore) { + bestScore = distance; + bestDrone = droneId; + } + } + if (bestDrone == null) { + 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. + * + * @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. + */ + private List> splitTrips( + List deliveries, + Drone drone, + LngLat servicePoint) { + List> trips = new ArrayList<>(); + List currentTrip = new ArrayList<>(); + for (MedDispatchRecRequest delivery : deliveries) { + currentTrip.add(delivery); + int requiredMoves = estimateTripMoves(servicePoint, currentTrip); + if (requiredMoves > drone.capability().maxMoves()) { + currentTrip.remove(currentTrip.size() - 1); + if (currentTrip.isEmpty()) { + throw new IllegalStateException( + "Delivery " + + delivery.id() + + " exceeds drone " + + drone.id() + + " move limit"); + } + trips.add(new ArrayList<>(currentTrip)); + currentTrip.clear(); + currentTrip.add(delivery); + } + } + if (!currentTrip.isEmpty()) { + int requiredMoves = estimateTripMoves(servicePoint, currentTrip); + if (requiredMoves > drone.capability().maxMoves()) { + throw new IllegalStateException( + "Delivery plan exceeds move limit for drone " + drone.id()); + } + trips.add(new ArrayList<>(currentTrip)); + } + return trips; + } + + /** + * 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 servicePoint Starting/ending location of the trip. + * @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 deliveries) { + if (deliveries == null || deliveries.isEmpty()) { + return null; + } + List flightPlans = new ArrayList<>(); + LngLat current = servicePoint; + int moves = 0; + + for (int i = 0; i < deliveries.size(); i++) { + MedDispatchRecRequest delivery = deliveries.get(i); + PathSegment toDelivery = computePath(current, delivery.delivery()); + List flightPath = new ArrayList<>(toDelivery.positions()); + if (!flightPath.isEmpty()) { + LngLat last = flightPath.get(flightPath.size() - 1); + if (!last.isSamePoint(delivery.delivery())) { + flightPath.add(delivery.delivery()); + } + } else { + flightPath.add(current); + flightPath.add(delivery.delivery()); + } + flightPath.add(delivery.delivery()); + moves += toDelivery.moves(); + + if (i == deliveries.size() - 1) { + PathSegment backHome = computePath( + delivery.delivery(), + servicePoint); + backHome.appendSkippingStart(flightPath); + moves += backHome.moves(); + current = servicePoint; + } else { + current = delivery.delivery(); + } + flightPlans.add(new Delivery(delivery.id(), flightPath)); + } + + float cost = drone.capability().costInitial() + + drone.capability().costFinal() + + (float) (drone.capability().costPerMove() * moves); + + DronePath path = new DronePath(drone.parseId(), flightPlans); + + return new TripResult(path, moves, cost); + } + + /** + * 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. + * @return Total moves required to fly the proposed itinerary. + */ + private int estimateTripMoves( + LngLat servicePoint, + List deliveries) { + if (deliveries.isEmpty()) { + return 0; + } + int moves = 0; + LngLat current = servicePoint; + for (MedDispatchRecRequest delivery : deliveries) { + PathSegment segment = computePath(current, delivery.delivery()); + moves += segment.moves(); + current = delivery.delivery(); + } + moves += computePath(current, servicePoint).moves(); + return moves; + } + + /** + * Build a path between {@code start} and {@code target} by repeatedly moving + * in snapped increments while avoiding restricted zones. + * + * @param start Start coordinate. + * @param target Destination coordinate. + * @return Sequence of visited coordinates and move count. + * @see #nextPosition(LngLat, LngLat) + */ + private PathSegment computePath(LngLat start, LngLat target) { + List positions = new ArrayList<>(); + if (start == null || target == null) { + return new PathSegment(positions, 0); + } + positions.add(start); + LngLat current = start; + int iterations = 0; + while (!gpsCalculationService.isCloseTo(current, target) && + iterations < MAX_SEGMENT_ITERATIONS) { + LngLat next = nextPosition(current, target); + if (next.isSamePoint(current)) { + break; + } + positions.add(next); + current = next; + iterations++; + } + if (!positions.get(positions.size() - 1).isSamePoint(target)) { + positions.add(target); + } + return new PathSegment(positions, Math.max(0, positions.size() - 1)); + } + + /** + * 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. + */ + private LngLat nextPosition(LngLat current, LngLat target) { + double desiredAngle = Math.toDegrees( + Math.atan2( + target.lat() - current.lat(), + target.lng() - current.lng())); + List candidateAngles = buildAngleCandidates(desiredAngle); + for (Angle angle : candidateAngles) { + LngLat next = gpsCalculationService.nextPosition(current, angle); + if (!isRestricted(next)) { + return next; + } + } + return current; + } + + /** + * 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. + * @return Ordered list of candidate snapped angles. + * @see Angle#snap(double) + */ + private List buildAngleCandidates(double desiredAngle) { + List angles = new LinkedList<>(); + Angle snapped = Angle.snap(desiredAngle); + angles.add(snapped); + for (int offset = 1; offset <= 8; offset++) { + angles.add(snapped.offset(offset)); + angles.add(snapped.offset(-offset)); + } + return angles; + } + + /** + * Check whether the provided coordinate falls inside any restricted region. + * + * @param position Coordinate to inspect. + * @return {@code true} if the position intersects a restricted area. + * @see #restrictedRegions + */ + private boolean isRestricted(LngLat position) { + for (Region region : restrictedRegions) { + if (gpsCalculationService.checkIsInRegion(position, region)) { return true; } } return false; } - private static List getNeighbours(LngLat p) { - var service = new GpsCalculationService(); - double angle = 0; - List positions = new ArrayList<>(); - final int directionCount = 8; - for (int i = 0; i < directionCount; i++) { - double directionAngle = angle + (i * 45); - LngLat nextPosition = service.nextPosition(p, directionAngle); - positions.add(nextPosition); + /** + * 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. + */ + private record PathSegment(List 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. + * + * @param target Mutable list to append to. + */ + private void appendSkippingStart(List target) { + for (int i = 1; i < positions.size(); i++) { + target.add(positions.get(i)); + } } - return positions; + } + + /** + * Bundle containing the calculated {@link DronePath}, total moves and + * financial cost for a single trip. + */ + private record TripResult(DronePath path, int moves, float cost) { } } diff --git a/src/test/java/io/github/js0ny/ilp_coursework/service/PathFinderServiceTest.java b/src/test/java/io/github/js0ny/ilp_coursework/service/PathFinderServiceTest.java new file mode 100644 index 0000000..623ff5d --- /dev/null +++ b/src/test/java/io/github/js0ny/ilp_coursework/service/PathFinderServiceTest.java @@ -0,0 +1,186 @@ +package io.github.js0ny.ilp_coursework.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +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; +import io.github.js0ny.ilp_coursework.data.common.LngLatAlt; +import io.github.js0ny.ilp_coursework.data.common.TimeWindow; +import io.github.js0ny.ilp_coursework.data.external.Drone; +import io.github.js0ny.ilp_coursework.data.external.RestrictedArea; +import io.github.js0ny.ilp_coursework.data.external.ServicePoint; +import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones; +import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest; +import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest.MedRequirement; +import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse; +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 { + + private static final String DRONE_ID = "test-drone"; + private static final LngLat SERVICE_POINT_COORD = new LngLat(0.0, 0.0); + private final ObjectMapper mapper = new ObjectMapper(); + + @Mock + private DroneInfoService droneInfoService; + + private PathFinderService pathFinderService; + + @BeforeEach + void setUpPathFinder() { + GpsCalculationService gpsCalculationService = new GpsCalculationService(); + + 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 } + ); + + 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.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 } + ); + + assertThat(response.totalMoves()).isGreaterThan(0); + assertThat(response.totalMoves()).isLessThanOrEqualTo(10); + assertThat(response.dronePaths()).hasSize(1); + DeliveryPathResponse.DronePath dronePath = response.dronePaths()[0]; + assertThat(dronePath.droneId()).isNotZero(); + 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(hasHoverAt(recordedPath, request.delivery())).isTrue(); + } + + @Test + void calculateDeliveryPathAsGeoJson_shouldReturnFeatureCollection() + throws IOException { + MedDispatchRecRequest request = createSampleRequest(); + + String geoJson = pathFinderService.calculateDeliveryPathAsGeoJson( + new MedDispatchRecRequest[] { request } + ); + + JsonNode root = mapper.readTree(geoJson); + assertThat(root.get("type").asText()).isEqualTo("FeatureCollection"); + JsonNode features = root.get("features"); + assertThat(features.size()).isEqualTo(1); + JsonNode feature = features.get(0); + assertThat(feature.get("type").asText()).isEqualTo("Feature"); + JsonNode props = feature.get("properties"); + assertThat(props.get("droneId").asInt()).isNotZero(); + assertThat(props.get("deliveryId").asInt()).isEqualTo(request.id()); + + JsonNode geometry = feature.get("geometry"); + assertThat(geometry.get("type").asText()).isEqualTo("LineString"); + JsonNode coordinates = geometry.get("coordinates"); + assertThat(coordinates.size()).isGreaterThanOrEqualTo(2); + + 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(); + } + + 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()) + ); + } + + private boolean hasHoverAt(List path, LngLat target) { + for (int i = 0; i < path.size() - 1; i++) { + if (samePoint(path.get(i), target) && samePoint(path.get(i + 1), target)) { + return true; + } + } + return false; + } + + 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 + ); + } +}