From 6795612079bd3273baf9bf6ecda143cc97d063ae Mon Sep 17 00:00:00 2001 From: js0ny Date: Thu, 27 Nov 2025 11:23:41 +0000 Subject: [PATCH] fix(cw1): fix sematic erros --- .../[POST] queryAvailableDrones/Complex.bru | 4 + .../[POST] queryAvailableDrones/Example.bru | 4 + .../Treat Null as False (Cooling).bru | 4 + .../controller/ApiController.java | 3 +- .../controller/GeoJsonDataController.java | 4 + .../ilp_coursework/data/common/Angle.java | 51 ++ .../ilp_coursework/data/common/LngLat.java | 14 + .../ilp_coursework/data/common/Region.java | 36 +- .../service/DroneInfoService.java | 163 ++---- .../service/GpsCalculationService.java | 5 +- .../controller/ApiControllerTest.java | 69 ++- .../service/GpsCalculationServiceTest.java | 472 ++++++++---------- 12 files changed, 435 insertions(+), 394 deletions(-) create mode 100644 src/main/java/io/github/js0ny/ilp_coursework/controller/GeoJsonDataController.java create mode 100644 src/main/java/io/github/js0ny/ilp_coursework/data/common/Angle.java diff --git a/ilp-cw-api/[POST] queryAvailableDrones/Complex.bru b/ilp-cw-api/[POST] queryAvailableDrones/Complex.bru index 93b23fa..d09f546 100644 --- a/ilp-cw-api/[POST] queryAvailableDrones/Complex.bru +++ b/ilp-cw-api/[POST] queryAvailableDrones/Complex.bru @@ -43,6 +43,10 @@ body:json { ] } +assert { + res.status: eq 200 +} + settings { encodeUrl: true timeout: 0 diff --git a/ilp-cw-api/[POST] queryAvailableDrones/Example.bru b/ilp-cw-api/[POST] queryAvailableDrones/Example.bru index 9fed8e5..1e65620 100644 --- a/ilp-cw-api/[POST] queryAvailableDrones/Example.bru +++ b/ilp-cw-api/[POST] queryAvailableDrones/Example.bru @@ -30,6 +30,10 @@ body:json { ] } +assert { + res.status: eq 200 +} + settings { encodeUrl: true timeout: 0 diff --git a/ilp-cw-api/[POST] queryAvailableDrones/Treat Null as False (Cooling).bru b/ilp-cw-api/[POST] queryAvailableDrones/Treat Null as False (Cooling).bru index c629adc..94a4326 100644 --- a/ilp-cw-api/[POST] queryAvailableDrones/Treat Null as False (Cooling).bru +++ b/ilp-cw-api/[POST] queryAvailableDrones/Treat Null as False (Cooling).bru @@ -26,6 +26,10 @@ body:json { ] } +assert { + res.status: eq 200 +} + settings { encodeUrl: true timeout: 0 diff --git a/src/main/java/io/github/js0ny/ilp_coursework/controller/ApiController.java b/src/main/java/io/github/js0ny/ilp_coursework/controller/ApiController.java index 05be9ea..1fba40b 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/controller/ApiController.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/controller/ApiController.java @@ -1,5 +1,6 @@ package io.github.js0ny.ilp_coursework.controller; +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 io.github.js0ny.ilp_coursework.data.request.DistanceRequest; @@ -79,7 +80,7 @@ public class ApiController { @PostMapping("/nextPosition") public LngLat getNextPosition(@RequestBody MovementRequest request) { LngLat start = request.start(); - double angle = request.angle(); + Angle angle = new Angle(request.angle()); return gpsService.nextPosition(start, angle); } 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 new file mode 100644 index 0000000..f637e55 --- /dev/null +++ b/src/main/java/io/github/js0ny/ilp_coursework/controller/GeoJsonDataController.java @@ -0,0 +1,4 @@ +package io.github.js0ny.ilp_coursework.controller; + +public class GeoJsonDataController { +} diff --git a/src/main/java/io/github/js0ny/ilp_coursework/data/common/Angle.java b/src/main/java/io/github/js0ny/ilp_coursework/data/common/Angle.java new file mode 100644 index 0000000..ea07457 --- /dev/null +++ b/src/main/java/io/github/js0ny/ilp_coursework/data/common/Angle.java @@ -0,0 +1,51 @@ +package io.github.js0ny.ilp_coursework.data.common; + +/** + * Represents the data transfer object for angle + * + * @param val value of the angle in degrees + */ +public record Angle(double degrees) { + private static final double STEP = 22.5; + private static final double EPSILON = 1e-10; + + public Angle { + if (degrees < 0 || degrees >= 360) { + 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) + double remainder = degrees % STEP; + + // Floating point modulo may have tiny errors, e.g. 45.0 % 22.5 could be 0.0 or 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 + ) { + throw new IllegalArgumentException( + "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" + ); + } + return new Angle(index * STEP); + } + + public static double toRadians(double degrees) { + return Math.toRadians(degrees); + } + + public double toRadians() { + return Math.toRadians(degrees); + } +} diff --git a/src/main/java/io/github/js0ny/ilp_coursework/data/common/LngLat.java b/src/main/java/io/github/js0ny/ilp_coursework/data/common/LngLat.java index 9a4ee50..6208b22 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/data/common/LngLat.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/data/common/LngLat.java @@ -8,6 +8,20 @@ package io.github.js0ny.ilp_coursework.data.common; * @param lat latitude of the coordinate/point */ 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 + ); + } + + if (lng < -180 || lng > 180) { + throw new IllegalArgumentException( + "Longitude must be between -180 and +180 degrees. Got: " + lng + ); + } + } + public LngLat(LngLatAlt coord) { this(coord.lng(), coord.lat()); } diff --git a/src/main/java/io/github/js0ny/ilp_coursework/data/common/Region.java b/src/main/java/io/github/js0ny/ilp_coursework/data/common/Region.java index 740a1d1..ff002a4 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/data/common/Region.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/data/common/Region.java @@ -1,11 +1,14 @@ package io.github.js0ny.ilp_coursework.data.common; -import io.github.js0ny.ilp_coursework.data.external.RestrictedArea; import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest; -import java.util.Arrays; + import java.util.List; import java.util.Objects; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.Map; + /** * Represents the data transfer object for a region definition *

@@ -48,4 +51,33 @@ public record Region(String name, List vertices) { LngLat last = vertices.getLast(); return Objects.equals(last, first); } + + public Map toGeoJson() { + try { + ObjectMapper mapper = new ObjectMapper(); + + List> ring = vertices.stream() + .map(v -> List.of(v.lng(), v.lat())) + .toList(); + + return Map.of( + "type", "Polygon", + "coordinates", List.of(ring)); + + } catch (Exception e) { + throw new RuntimeException("Failed to generate GeoJSON", e); + } + } + + public String toGeoJsonString() { + try { + ObjectMapper mapper = new ObjectMapper(); + + var geoJson = toGeoJson(); + + return mapper.writeValueAsString(geoJson); + } catch (Exception e) { + throw new RuntimeException("Failed to generate GeoJSON", e); + } + } } diff --git a/src/main/java/io/github/js0ny/ilp_coursework/service/DroneInfoService.java b/src/main/java/io/github/js0ny/ilp_coursework/service/DroneInfoService.java index 6ecd613..829fff6 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/service/DroneInfoService.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/service/DroneInfoService.java @@ -1,19 +1,14 @@ package io.github.js0ny.ilp_coursework.service; -import static io.github.js0ny.ilp_coursework.util.AttrComparator.isValueMatched; - -import com.fasterxml.jackson.databind.JsonNode; +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; 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.AttrQueryRequest; 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.Delivery; -import io.github.js0ny.ilp_coursework.util.AttrOperator; import java.net.URI; import java.time.DayOfWeek; import java.time.LocalDate; @@ -28,7 +23,6 @@ import org.springframework.web.client.RestTemplate; public class DroneInfoService { private final String baseUrl; - private final String dronesEndpoint = "drones"; private final String dronesForServicePointsEndpoint = "drones-for-service-points"; public static final String servicePointsEndpoint = "service-points"; @@ -60,7 +54,7 @@ public class DroneInfoService { * * @param state determines the capability filtering * @return if {@code state} is true, return ids of drones with cooling - * capability, else without cooling + * capability, else without cooling * @see io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean) */ public List dronesWithCooling(boolean state) { @@ -96,10 +90,6 @@ public class DroneInfoService { public Drone droneDetail(String id) { List drones = fetchAllDrones(); - if (drones == null) { - throw new NullPointerException("drone cannot be found"); - } - for (var drone : drones) { if (drone.id().equals(id)) { return drone; @@ -119,16 +109,12 @@ public class DroneInfoService { * Associated service method with * * @param rec array of medical dispatch records - * @return array of drone ids that match all the requirements + * @return List of drone ids that match all the requirements * @see io.github.js0ny.ilp_coursework.controller.DroneController#queryAvailableDrones */ public List dronesMatchesRequirements(MedDispatchRecRequest[] rec) { List drones = fetchAllDrones(); - if (drones == null) { - return new ArrayList<>(); - } - if (rec == null || rec.length == 0) { return drones .stream() @@ -146,7 +132,7 @@ public class DroneInfoService { .filter(d -> Arrays.stream(rec) .filter(r -> r != null && r.requirements() != null) - .allMatch(r -> meetsRequirement(d, r)) + .allMatch(r -> droneMatchesRequirement(d, r)) ) .map(Drone::id) .collect(Collectors.toList()); @@ -162,7 +148,10 @@ public class DroneInfoService { * is invalid (capacity and id cannot be null * in {@code MedDispathRecDto}) */ - public boolean meetsRequirement(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"); @@ -184,7 +173,8 @@ public class DroneInfoService { boolean requiredHeating = requirements.heating(); // Case 1: required is null: We don't care about it - // Case 2: required is false: We don't care about it (high capability adapts to low requirements) + // Case 2: required is false: We don't care about it (high capability adapts to + // low requirements) // Case 3: capability is true: Then always matches // See: https://piazza.com/class/me9vp64lfgf4sn/post/100 boolean matchesCooling = !requiredCooling || capability.cooling(); @@ -197,7 +187,8 @@ public class DroneInfoService { matchesHeating && checkAvailability(drone.id(), record) ); // && - // checkCost(drone, record) // checkCost is more expensive than checkAvailability + // checkCost(drone, record) // checkCost is more expensive than + // checkAvailability } /** @@ -276,72 +267,15 @@ public class DroneInfoService { return null; } - // private Set parseObstacles() { - // URI restrictedAreasUrl = URI.create(baseUrl).resolve( - // restrictedAreasEndpoint - // ); - // - // RestrictedArea[] restrictedAreas = restTemplate.getForObject( - // restrictedAreasUrl, - // RestrictedArea[].class - // ); - // - // assert restrictedAreas != null; - // Set obstacles = new HashSet<>(); - // for (var ra : restrictedAreas) { - // obstacles.add(new LngLat(ra.location())); - // } - // return obstacles; - // } - - // public DeliveryPathResponse calcDeliveryPath( - // MedDispatchRecRequest[] records - // ) { - // URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint); - // Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class); - // List restrictedAreas = fetchRestrictedAreas(); - // List totalPath = new ArrayList<>(); - // List deliveries = new ArrayList<>(); - - // int moves = 0; - // float cost = 0; - // for (var record : records) { - // assert drones != null; - // Drone[] possibleDrones = Arrays.stream(drones) - // .filter(d -> meetsRequirement(d, record)) - // .toArray(Drone[]::new); - // int shortestPathCount = Integer.MAX_VALUE; - // float lowestCost = Float.MAX_VALUE; - // List shortestPath = null; - // for (var d : possibleDrones) { - // var start = queryServicePointLocationByDroneId(d.id()); - // List path = PathFinderService.findPath( - // start, - // record.delivery(), - // restrictedAreas - // ); - // float pathCost = path.size() * d.capability().costPerMove(); - // if ( - // path.size() < d.capability().maxMoves() && - // pathCost < lowestCost - // ) { - // shortestPathCount = path.size(); - // lowestCost = pathCost; - // shortestPath = path; - // } - // } - // // deliveries.add(new Delivery(record.id(), shortestPath)); - // } - // // return new - // } - - private List fetchAllDrones() { + public List fetchAllDrones() { + String dronesEndpoint = "drones"; URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint); Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class); + assert drones != null; return Arrays.asList(drones); } - private List fetchRestrictedAreas() { + public List fetchRestrictedAreas() { URI restrictedUrl = URI.create(baseUrl).resolve( restrictedAreasEndpoint ); @@ -350,13 +284,22 @@ public class DroneInfoService { RestrictedArea[].class ); assert restrictedAreas != null; - List restrictedAreaList = Arrays.asList( - restrictedAreas - ); - return restrictedAreaList; + return Arrays.asList(restrictedAreas); } - private List fetchServicePoints() { + public List fetchRestrictedAreasInGeoJson() + throws JsonProcessingException { + var mapper = new ObjectMapper(); + var ras = fetchRestrictedAreas(); + var geoJson = ras + .stream() + .map(RestrictedArea::toRegion) + .map(Region::toGeoJson) + .toList(); + return Collections.singletonList(mapper.writeValueAsString(geoJson)); + } + + public List fetchServicePoints() { URI servicePointUrl = URI.create(baseUrl).resolve( servicePointsEndpoint ); @@ -365,11 +308,10 @@ public class DroneInfoService { ServicePoint[].class ); assert servicePoints != null; - List servicePointList = Arrays.asList(servicePoints); - return servicePointList; + return Arrays.asList(servicePoints); } - private List fetchDronesForServicePoints() { + public List fetchDronesForServicePoints() { URI servicePointDronesUrl = URI.create(baseUrl).resolve( dronesForServicePointsEndpoint ); @@ -378,43 +320,6 @@ public class DroneInfoService { ServicePointDrones[].class ); assert servicePointDrones != null; - List servicePointDronesList = Arrays.asList( - servicePointDrones - ); - return servicePointDronesList; - } - - // NOTE: Not used. - private boolean checkCost(Drone drone, MedDispatchRecRequest rec) { - if (rec.delivery() == null) { - return true; - } - URI droneUrl = URI.create(baseUrl).resolve( - dronesForServicePointsEndpoint - ); - ServicePointDrones[] servicePoints = restTemplate.getForObject( - droneUrl, - ServicePointDrones[].class - ); - - GpsCalculationService gpsService = new GpsCalculationService(); - - double steps = gpsService.calculateSteps( - queryServicePointLocationByDroneId(drone.id()), - rec.delivery() - ); - - if (steps > drone.capability().maxMoves()) { - return false; - } - - float baseCost = - drone.capability().costInitial() + drone.capability().costFinal(); - - double cost = baseCost + drone.capability().costPerMove() * steps; - - var requiredMaxCost = rec.requirements().maxCost(); - - return cost <= requiredMaxCost; + return Arrays.asList(servicePointDrones); } } diff --git a/src/main/java/io/github/js0ny/ilp_coursework/service/GpsCalculationService.java b/src/main/java/io/github/js0ny/ilp_coursework/service/GpsCalculationService.java index 3cf22b7..2e3abd1 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/service/GpsCalculationService.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/service/GpsCalculationService.java @@ -1,5 +1,6 @@ package io.github.js0ny.ilp_coursework.service; +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 io.github.js0ny.ilp_coursework.data.request.DistanceRequest; @@ -84,8 +85,8 @@ public class GpsCalculationService { * @see #STEP * @see io.github.js0ny.ilp_coursework.controller.ApiController#getNextPosition(MovementRequest) */ - public LngLat nextPosition(LngLat start, double angle) { - double rad = Math.toRadians(angle); // Convert to radian for Java triangle function calculation + public LngLat nextPosition(LngLat start, Angle angle) { + double rad = angle.toRadians(); double newLng = Math.cos(rad) * STEP + start.lng(); double newLat = Math.sin(rad) * STEP + start.lat(); return new LngLat(newLng, newLat); diff --git a/src/test/java/io/github/js0ny/ilp_coursework/controller/ApiControllerTest.java b/src/test/java/io/github/js0ny/ilp_coursework/controller/ApiControllerTest.java index d99dc0d..abd310f 100644 --- a/src/test/java/io/github/js0ny/ilp_coursework/controller/ApiControllerTest.java +++ b/src/test/java/io/github/js0ny/ilp_coursework/controller/ApiControllerTest.java @@ -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; import io.github.js0ny.ilp_coursework.data.request.DistanceRequest; @@ -101,6 +102,25 @@ public class ApiControllerTest { ) .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()); + } } @Nested @@ -146,6 +166,25 @@ public class ApiControllerTest { ) .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 } } + """; + when( + service.calculateDistance(any(LngLat.class), isNull()) + ).thenThrow(new NullPointerException()); + mockMvc + .perform( + post(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(req) + ) + .andExpect(status().isBadRequest()); + } } @Nested @@ -162,7 +201,7 @@ public class ApiControllerTest { LngLat p = new LngLat(0, 0); var req = new MovementRequest(p, 0); when( - service.nextPosition(any(LngLat.class), anyDouble()) + service.nextPosition(any(LngLat.class), any(Angle.class)) ).thenReturn(expected); var mock = mockMvc.perform( post(endpoint) @@ -187,7 +226,7 @@ public class ApiControllerTest { "angle": 180 } """; - when(service.nextPosition(isNull(), anyDouble())).thenThrow( + when(service.nextPosition(isNull(), any(Angle.class))).thenThrow( new NullPointerException() ); mockMvc @@ -198,6 +237,32 @@ public class ApiControllerTest { ) .andExpect(MockMvcResultMatchers.status().isBadRequest()); } + + @Test + @DisplayName("POST /nextPosition -> 400 Bad Request: Semantic errors") + 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()); + } } @Nested diff --git a/src/test/java/io/github/js0ny/ilp_coursework/service/GpsCalculationServiceTest.java b/src/test/java/io/github/js0ny/ilp_coursework/service/GpsCalculationServiceTest.java index 8edd77f..fe9ce8c 100644 --- a/src/test/java/io/github/js0ny/ilp_coursework/service/GpsCalculationServiceTest.java +++ b/src/test/java/io/github/js0ny/ilp_coursework/service/GpsCalculationServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; 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; @@ -52,7 +53,7 @@ public class GpsCalculationServiceTest { @Test @DisplayName("Edge Case: Points are Identical") void calculateDistance_shouldReturnZero_whenPointsAreIdentical() { - var p1 = new LngLat(123.85, 983.2119); + var p1 = new LngLat(12.85, 68.2119); double expected = 0.0; double actual = service.calculateDistance(p1, p1); assertThat(actual).isEqualTo(expected); @@ -61,8 +62,8 @@ public class GpsCalculationServiceTest { @Test @DisplayName("Edge Case: Longitudinal-only movement") void calculateDistance_shouldReturnCorrectDistance_forPointsWithSameLatitude() { - var p1 = new LngLat(123.85, 983.2119); - var p2 = new LngLat(133.85, 983.2119); + var p1 = new LngLat(23.85, 83.2119); + var p2 = new LngLat(33.85, 83.2119); double expected = 10.0; double actual = service.calculateDistance(p1, p2); assertThat(actual).isCloseTo(expected, within(PRECISION)); @@ -71,8 +72,8 @@ public class GpsCalculationServiceTest { @Test @DisplayName("Edge Case: Latitude-only movement") void calculateDistance_shouldReturnCorrectDistance_forPointsWithSameLongitude() { - var p1 = new LngLat(123.85, 983.2119); - var p2 = new LngLat(123.85, 973.2119); + var p1 = new LngLat(123.85, 68.2119); + var p2 = new LngLat(123.85, 58.2119); double expected = 10.0; double actual = service.calculateDistance(p1, p2); assertThat(actual).isCloseTo(expected, within(PRECISION)); @@ -106,7 +107,7 @@ public class GpsCalculationServiceTest { @Test @DisplayName("True: Two points are the same") void isCloseTo_shouldReturnTrue_whenPointsAreIdentical() { - var p1 = new LngLat(151.86, 285.37); + var p1 = new LngLat(15.86, 28.37); boolean expected = true; boolean actual = service.isCloseTo(p1, p1); assertThat(actual).isEqualTo(expected); @@ -153,7 +154,7 @@ public class GpsCalculationServiceTest { @DisplayName("General Case: nextPosition in East direction (0 degrees)") void nextPosition_shouldMoveEast_forAngleZero() { var start = new LngLat(0.0, 0.0); - double angle = 0; + Angle angle = new Angle(0); // For 0 degrees, cos(0)=1, sin(0)=0. Move happens entirely on lng axis. var expected = new LngLat(STEP, 0.0); @@ -175,7 +176,7 @@ public class GpsCalculationServiceTest { ) void nextPosition_shouldMoveNorth_forAngle90() { var start = new LngLat(0.0, 0.0); - double angle = 90; + Angle angle = new Angle(90); // For 90 degrees, cos(90)=0, sin(90)=1. Move happens entirely on lat axis. var expected = new LngLat(0.0, STEP); @@ -197,7 +198,7 @@ public class GpsCalculationServiceTest { ) void nextPosition_shouldMoveWest_forAngle180() { var start = new LngLat(0.0, 0.0); - double angle = 180; + Angle angle = new Angle(180); // For 180 degrees, cos(180)=-1, sin(180)=0. Move happens entirely on negative // lng axis. var expected = new LngLat(-STEP, 0.0); @@ -220,7 +221,7 @@ public class GpsCalculationServiceTest { ) void nextPosition_shouldMoveSouth_forAngle270() { var start = new LngLat(0.0, 0.0); - double angle = 270; + Angle angle = new Angle(270); // For 270 degrees, cos(270)=0, sin(270)=-1. Move happens entirely on negative // lat axis. var expected = new LngLat(0.0, -STEP); @@ -243,10 +244,10 @@ public class GpsCalculationServiceTest { ) void nextPosition_shouldMoveNortheast_forAngle45() { var start = new LngLat(0.0, 0.0); - double angle = 45; + Angle angle = new Angle(45); // Δlng = step * cos(45°), Δlat = step * sin(45°) - double expectedLng = STEP * Math.cos(Math.toRadians(angle)); - double expectedLat = STEP * Math.sin(Math.toRadians(angle)); + double expectedLng = STEP * Math.cos(angle.toRadians()); + double expectedLat = STEP * Math.sin(angle.toRadians()); var expected = new LngLat(expectedLng, expectedLat); var actual = service.nextPosition(start, angle); @@ -261,261 +262,216 @@ public class GpsCalculationServiceTest { ); } - @Test - @DisplayName("Edge Case: Angle larger than 360 should wrap around") - void nextPosition_shouldHandleAngleGreaterThan360() { - var start = new LngLat(0.0, 0.0); - // 405 degrees is equivalent to 45 degrees (405 % 360 = 45). - double angle = 405; - double equivalentAngle = 45; - double expectedLng = - STEP * Math.cos(Math.toRadians(equivalentAngle)); - double expectedLat = - STEP * Math.sin(Math.toRadians(equivalentAngle)); - var expected = new LngLat(expectedLng, expectedLat); + @Nested + @DisplayName( + "Test for checkIsInRegion(LngLatDto, RegionDto) -> boolean" + ) + class CheckIsInRegionTests { - var actual = service.nextPosition(start, angle); - - assertThat(actual.lng()).isCloseTo( - expected.lng(), - within(PRECISION) - ); - assertThat(actual.lat()).isCloseTo( - expected.lat(), - within(PRECISION) - ); - } - - @Test - @DisplayName("Edge Case: Negative angle should work correctly") - void nextPosition_shouldHandleNegativeAngle() { - var start = new LngLat(0.0, 0.0); - // A negative angle of -45° corresponds to the Southeast direction. - double angle = -45; - double expectedLng = STEP * Math.cos(Math.toRadians(angle)); - double expectedLat = STEP * Math.sin(Math.toRadians(angle)); - var expected = new LngLat(expectedLng, expectedLat); - - var actual = service.nextPosition(start, angle); - - assertThat(actual.lng()).isCloseTo( - expected.lng(), - within(PRECISION) - ); - assertThat(actual.lat()).isCloseTo( - expected.lat(), - within(PRECISION) - ); - } - } - - @Nested - @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) - ) - ); - - @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) - ) - ); - boolean expected = false; - boolean actual = service.checkIsInRegion(position, region); - assertThat(actual).isEqualTo(expected); - } - - @Test - @DisplayName("General Case: Simple Rectangle") - void isInRegion_shouldReturnTrue_forSimpleRectangle() { - var position = new LngLat(1.0, 1.0); - boolean expected = true; - boolean actual = service.checkIsInRegion( - position, - RECTANGLE_REGION - ); - assertThat(actual).isEqualTo(expected); - } - - @Test - @DisplayName("General Case: Simple Rectangle") - void isInRegion_shouldReturnFalse_forSimpleRectangle() { - var position = new LngLat(3.0, 1.0); - boolean expected = false; - boolean actual = service.checkIsInRegion( - position, - RECTANGLE_REGION - ); - assertThat(actual).isEqualTo(expected); - } - - @Test - @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) - ) - ); - boolean expected = true; - boolean actual = service.checkIsInRegion(position, region); - assertThat(actual).isEqualTo(expected); - } - - @Test - @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) - ) - ); - boolean expected = true; - boolean actual = service.checkIsInRegion(position, region); - assertThat(actual).isEqualTo(expected); - } - - @Test - @DisplayName("Edge Case: Point on Lower Edge of Rectangle") - void isInRegion_shouldReturnTrue_whenPointOnLowerEdge() { - var position = new LngLat(0.0, 1.0); - boolean expected = true; - boolean actual = service.checkIsInRegion( - position, - RECTANGLE_REGION - ); - assertThat(actual).isEqualTo(expected); - } - - @Test - @DisplayName("Edge Case: Point on Upper Edge of Rectangle") - void isInRegion_shouldReturnTrue_whenPointOnUpperEdge() { - var position = new LngLat(2.0, 1.0); - boolean expected = true; - boolean actual = service.checkIsInRegion( - position, - RECTANGLE_REGION - ); - assertThat(actual).isEqualTo(expected); - } - - @Test - @DisplayName("Edge Case: Point on Left Edge") - void isInRegion_shouldReturnTrue_whenPointOnLeftEdge() { - var position = new LngLat(0.0, 1.0); - boolean expected = true; - boolean actual = service.checkIsInRegion( - position, - RECTANGLE_REGION - ); - assertThat(actual).isEqualTo(expected); - } - - @Test - @DisplayName("Edge Case: Point on Lower Vertex") - void isInRegion_shouldReturnTrue_whenPointOnLowerVertex() { - var position = new LngLat(0.0, 0.0); - boolean expected = true; - boolean actual = service.checkIsInRegion( - position, - RECTANGLE_REGION - ); - assertThat(actual).isEqualTo(expected); - } - - @Test - @DisplayName("Edge Case: Point on Upper Vertex") - void isInRegion_shouldReturnTrue_whenPointOnUpperVertex() { - var position = new LngLat(2.0, 2.0); - boolean expected = true; - boolean actual = service.checkIsInRegion( - position, - RECTANGLE_REGION - ); - assertThat(actual).isEqualTo(expected); - } - - @Test - @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."); - } - - @Test - @DisplayName("Edge Case: Region is not closed") - void isInRegion_shouldThrowExceptions_whenRegionNotClose() { - var position = new LngLat(2.0, 2.0); - var region = new Region( + 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, -1.0) + new LngLat(0.0, 0.0) ) ); - assertThatThrownBy(() -> { - service.checkIsInRegion(position, region); - }) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Region is not closed."); - } - @Test - @DisplayName("Edge Case: Vertex list is empty") - 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."); + @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) + ) + ); + boolean expected = false; + boolean actual = service.checkIsInRegion(position, region); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("General Case: Simple Rectangle") + void isInRegion_shouldReturnTrue_forSimpleRectangle() { + var position = new LngLat(1.0, 1.0); + boolean expected = true; + boolean actual = service.checkIsInRegion( + position, + RECTANGLE_REGION + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("General Case: Simple Rectangle") + void isInRegion_shouldReturnFalse_forSimpleRectangle() { + var position = new LngLat(3.0, 1.0); + boolean expected = false; + boolean actual = service.checkIsInRegion( + position, + RECTANGLE_REGION + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + @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) + ) + ); + boolean expected = true; + boolean actual = service.checkIsInRegion(position, region); + assertThat(actual).isEqualTo(expected); + } + + @Test + @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) + ) + ); + boolean expected = true; + boolean actual = service.checkIsInRegion(position, region); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("Edge Case: Point on Lower Edge of Rectangle") + void isInRegion_shouldReturnTrue_whenPointOnLowerEdge() { + var position = new LngLat(0.0, 1.0); + boolean expected = true; + boolean actual = service.checkIsInRegion( + position, + RECTANGLE_REGION + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("Edge Case: Point on Upper Edge of Rectangle") + void isInRegion_shouldReturnTrue_whenPointOnUpperEdge() { + var position = new LngLat(2.0, 1.0); + boolean expected = true; + boolean actual = service.checkIsInRegion( + position, + RECTANGLE_REGION + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("Edge Case: Point on Left Edge") + void isInRegion_shouldReturnTrue_whenPointOnLeftEdge() { + var position = new LngLat(0.0, 1.0); + boolean expected = true; + boolean actual = service.checkIsInRegion( + position, + RECTANGLE_REGION + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("Edge Case: Point on Lower Vertex") + void isInRegion_shouldReturnTrue_whenPointOnLowerVertex() { + var position = new LngLat(0.0, 0.0); + boolean expected = true; + boolean actual = service.checkIsInRegion( + position, + RECTANGLE_REGION + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("Edge Case: Point on Upper Vertex") + void isInRegion_shouldReturnTrue_whenPointOnUpperVertex() { + var position = new LngLat(2.0, 2.0); + boolean expected = true; + boolean actual = service.checkIsInRegion( + position, + RECTANGLE_REGION + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + @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."); + } + + @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."); + } + + @Test + @DisplayName("Edge Case: Vertex list is empty") + 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."); + } } } }