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 9647226..ba22bfa 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 @@ -6,10 +6,11 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import io.github.js0ny.ilp_coursework.dto.DistanceRequestDto; -import io.github.js0ny.ilp_coursework.dto.LngLatDto; -import io.github.js0ny.ilp_coursework.dto.MovementRequestDto; -import io.github.js0ny.ilp_coursework.dto.RegionCheckRequestDto; +import io.github.js0ny.ilp_coursework.data.DistanceRequestDto; +import io.github.js0ny.ilp_coursework.data.LngLatDto; +import io.github.js0ny.ilp_coursework.data.MovementRequestDto; +import io.github.js0ny.ilp_coursework.data.RegionCheckRequestDto; +import io.github.js0ny.ilp_coursework.data.RegionDto; import io.github.js0ny.ilp_coursework.service.GpsCalculationService; @RestController @@ -52,6 +53,8 @@ public class ApiController { @PostMapping("/isInRegion") public boolean getIsInRegion(@RequestBody RegionCheckRequestDto request) { - return true; + LngLatDto position = request.position(); + RegionDto region = request.region(); + return gpsService.checkIsInRegion(position, region); } } diff --git a/src/main/java/io/github/js0ny/ilp_coursework/dto/DistanceRequestDto.java b/src/main/java/io/github/js0ny/ilp_coursework/data/DistanceRequestDto.java similarity index 64% rename from src/main/java/io/github/js0ny/ilp_coursework/dto/DistanceRequestDto.java rename to src/main/java/io/github/js0ny/ilp_coursework/data/DistanceRequestDto.java index 28b591e..e9956ea 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/dto/DistanceRequestDto.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/data/DistanceRequestDto.java @@ -1,4 +1,4 @@ -package io.github.js0ny.ilp_coursework.dto; +package io.github.js0ny.ilp_coursework.data; public record DistanceRequestDto(LngLatDto position1, LngLatDto position2) { } diff --git a/src/main/java/io/github/js0ny/ilp_coursework/dto/LngLatDto.java b/src/main/java/io/github/js0ny/ilp_coursework/data/LngLatDto.java similarity index 54% rename from src/main/java/io/github/js0ny/ilp_coursework/dto/LngLatDto.java rename to src/main/java/io/github/js0ny/ilp_coursework/data/LngLatDto.java index cf79262..95679e9 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/dto/LngLatDto.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/data/LngLatDto.java @@ -1,4 +1,4 @@ -package io.github.js0ny.ilp_coursework.dto; +package io.github.js0ny.ilp_coursework.data; public record LngLatDto(double lng, double lat) { diff --git a/src/main/java/io/github/js0ny/ilp_coursework/dto/MovementRequestDto.java b/src/main/java/io/github/js0ny/ilp_coursework/data/MovementRequestDto.java similarity index 81% rename from src/main/java/io/github/js0ny/ilp_coursework/dto/MovementRequestDto.java rename to src/main/java/io/github/js0ny/ilp_coursework/data/MovementRequestDto.java index 2c6e0dd..e9b87ab 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/dto/MovementRequestDto.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/data/MovementRequestDto.java @@ -1,4 +1,4 @@ -package io.github.js0ny.ilp_coursework.dto; +package io.github.js0ny.ilp_coursework.data; /** * Represents the data transfer object for a movement action request. diff --git a/src/main/java/io/github/js0ny/ilp_coursework/data/RegionCheckRequestDto.java b/src/main/java/io/github/js0ny/ilp_coursework/data/RegionCheckRequestDto.java new file mode 100644 index 0000000..aabf752 --- /dev/null +++ b/src/main/java/io/github/js0ny/ilp_coursework/data/RegionCheckRequestDto.java @@ -0,0 +1,5 @@ +package io.github.js0ny.ilp_coursework.data; + +public record RegionCheckRequestDto(LngLatDto position, RegionDto region) { + +} diff --git a/src/main/java/io/github/js0ny/ilp_coursework/dto/RegionDto.java b/src/main/java/io/github/js0ny/ilp_coursework/data/RegionDto.java similarity index 86% rename from src/main/java/io/github/js0ny/ilp_coursework/dto/RegionDto.java rename to src/main/java/io/github/js0ny/ilp_coursework/data/RegionDto.java index 72a88a2..31c99ef 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/dto/RegionDto.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/data/RegionDto.java @@ -1,11 +1,11 @@ -package io.github.js0ny.ilp_coursework.dto; +package io.github.js0ny.ilp_coursework.data; import java.util.List; import java.util.Objects; public record RegionDto(String name, List vertices) { - public boolean isClose() { + public boolean isClosed() { // Magic number 4: For a polygon, 3 edges is required. // In this dto, edges + 1 vertices is required. if (vertices == null || vertices.size() < 4) { diff --git a/src/main/java/io/github/js0ny/ilp_coursework/dto/RegionCheckRequestDto.java b/src/main/java/io/github/js0ny/ilp_coursework/dto/RegionCheckRequestDto.java deleted file mode 100644 index 9e59933..0000000 --- a/src/main/java/io/github/js0ny/ilp_coursework/dto/RegionCheckRequestDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.js0ny.ilp_coursework.dto; - -public record RegionCheckRequestDto(LngLatDto position) { - -} diff --git a/src/main/java/io/github/js0ny/ilp_coursework/exception/GlobalExceptionHandler.java b/src/main/java/io/github/js0ny/ilp_coursework/exception/GlobalExceptionHandler.java index f479c23..0db8f12 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/exception/GlobalExceptionHandler.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/exception/GlobalExceptionHandler.java @@ -1,5 +1,34 @@ package io.github.js0ny.ilp_coursework.exception; -public class GlobalExceptionHandler { +import java.util.Map; +import java.util.Optional; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { + return Map.of("status", "400", "error", "Invalid JSON request body."); + } + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleIllegalArgument(IllegalArgumentException ex) { + String errorMessage = Optional.ofNullable(ex.getMessage()) + .orElse("Invalid argument provided."); + return Map.of("status", "400", "error", errorMessage); + } + + // @ExceptionHandler(NullPointerException.class) + // @ResponseStatus(HttpStatus.BAD_REQUEST) + // public Map handleNullPointerException(NullPointerException + // ex) { + // return Map.of("error", "Invalid JSON request body."); + // } } 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 10efcd3..6da3d0b 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,11 +1,15 @@ package io.github.js0ny.ilp_coursework.service; +import io.github.js0ny.ilp_coursework.data.LngLatDto; +import io.github.js0ny.ilp_coursework.data.RegionDto; + +import java.util.List; + import org.springframework.stereotype.Service; -import io.github.js0ny.ilp_coursework.dto.LngLatDto; - @Service public class GpsCalculationService { + private static final double STEP = 0.00015; private static final double CLOSE_THRESHOLD = STEP; @@ -20,6 +24,16 @@ public class GpsCalculationService { return distance < CLOSE_THRESHOLD; } + /** + * Called from ApiController.getNextPosition. + *

+ * Returns the next position moved from start in the direction with angle, with step size + * 0.00015 + * + * @param start The coordinate of the original start point. + * @param angle The direction to be moved in angle. + * @return The next position moved from start + */ public LngLatDto nextPosition(LngLatDto start, double angle) { double rad = Math.toRadians(angle); double newLng = Math.cos(rad) * STEP + start.lng(); @@ -27,4 +41,100 @@ public class GpsCalculationService { return new LngLatDto(newLng, newLat); } + /** + * Called from ApiController.getIsInRegion. + *

+ * Used to check if the given position + * is inside the region, on edge and vertex is considered as inside. + * + * @param position The coordinate of the position. + * @param region A RegionDto that contains name and a list of LngLatDto + * @return true if position is inside the region. + * @throws IllegalArgumentException If region is not closed + */ + public boolean checkIsInRegion(LngLatDto position, RegionDto region) throws IllegalArgumentException { + if (!region.isClosed()) { // call method from RegionDto to check if not closed + throw new IllegalArgumentException("Region is not closed."); + } + return rayCasting(position, region.vertices()); + } + + + /** + * Helper function to 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 point + * sits inside. + * @return If the point sits inside the polygon then + * return True + */ + private boolean rayCasting(LngLatDto point, List polygon) { + int intersections = 0; + int n = polygon.size(); + for (int i = 0; i < n; ++i) { + LngLatDto a = polygon.get(i); + LngLatDto b = polygon.get((i + 1) % n); // Next vertex + + if (isPointOnEdge(point, a, b)) { + return true; + } + + // Ensure that a is norther than b, in order to easy classification + if (a.lat() > b.lat()) { + LngLatDto temp = a; + a = b; + b = temp; + } + + // The point is not between a and b in latitude mean, skip this loop + if (point.lat() < a.lat() || point.lat() >= b.lat()) { + continue; + } + + // Skip the case of horizontal edge, already handled in `isPointOnEdge`:w + if (a.lat() == b.lat()) { + continue; + } + + double xIntersection = a.lng() + ((point.lat() - a.lat()) * (b.lng() - a.lng())) / (b.lat() - a.lat()); + +// // The point is on the edge +// if (xIntersection == point.lng()) { +// return true; +// } + + if (xIntersection > point.lng()) { + ++intersections; + } + } + // If intersections are odd, ray-casting returns true, which the point sits + // inside the polygon; + // If intersections are even, the point does not sit inside the polygon. + return intersections % 2 == 1; + } + + /** + * Helper function from rayCasting that used to simply calculation
+ * Used to check if point p is on the edge formed by a and b + * + * @param p point to be checked on the edge + * @param a point that forms the edge + * @param b point that forms the edge + * @return boolean, if p is on ab then true + */ + private boolean isPointOnEdge(LngLatDto p, LngLatDto a, LngLatDto 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()); + 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()); + boolean isWithinLat = p.lat() >= Math.min(a.lat(), b.lat()) && p.lat() <= Math.max(a.lat(), b.lat()); + + return isWithinLng && isWithinLat; + } } 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 new file mode 100644 index 0000000..a82cb84 --- /dev/null +++ b/src/test/java/io/github/js0ny/ilp_coursework/controller/ApiControllerTest.java @@ -0,0 +1,55 @@ +package io.github.js0ny.ilp_coursework.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.js0ny.ilp_coursework.data.DistanceRequestDto; +import io.github.js0ny.ilp_coursework.data.LngLatDto; +import io.github.js0ny.ilp_coursework.service.GpsCalculationService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +@WebMvcTest(ApiController.class) +public class ApiControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private GpsCalculationService gpsCalculationService; + + @Test + void getUid_shouldReturnStudentIdFromService() throws Exception { + String endpoint = "/api/v1/uid"; + String expected = "s2522255"; + var mock = mockMvc.perform(get(endpoint)); + mock.andExpect(MockMvcResultMatchers.status().isOk()); + mock.andExpect(MockMvcResultMatchers.content().string(expected)); + } + + @Test + void getDistance_shouldReturnDoubleFromService_whenCorrectInput() throws Exception { + double expected = 5.0; + String endpoint = "/api/v1/distanceTo"; + LngLatDto p1 = new LngLatDto(0, 4.0); + LngLatDto p2 = new LngLatDto(3.0, 0); + var req = new DistanceRequestDto(p1, p2); + var mock = mockMvc.perform( + post(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req)) + ); + + mock.andExpect(MockMvcResultMatchers.status().isOk()); + mock.andExpect(MockMvcResultMatchers.content().string(String.valueOf(expected))); + } +} 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 new file mode 100644 index 0000000..e0728b0 --- /dev/null +++ b/src/test/java/io/github/js0ny/ilp_coursework/service/GpsCalculationServiceTest.java @@ -0,0 +1,354 @@ +package io.github.js0ny.ilp_coursework.service; + +import io.github.js0ny.ilp_coursework.data.LngLatDto; +import io.github.js0ny.ilp_coursework.data.RegionDto; +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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.within; + + +public class GpsCalculationServiceTest { + + private static final double STEP = 0.00015; + private static final double CLOSE_THRESHOLD = STEP; + private static final double PRECISION = 1e-9; + + private GpsCalculationService service; + + @BeforeEach + void setUpService() { + service = new GpsCalculationService(); + } + + @Nested + @DisplayName("Test for calculateDistance(LngLatDto, LngLatDto) -> double") + class CalculateDistanceTests { + @Test + @DisplayName("False: Given Example For Testing") + void isCloseTo_shouldReturnFalse_givenExample() { + var p1 = new LngLatDto(-3.192473, 55.946233); + var p2 = new LngLatDto(-3.192473, 55.942617); + double expected = 0.0036; + double actual = service.calculateDistance(p1, p2); + assertThat(actual).isCloseTo(expected, within(1e-4)); + } + + @Test + @DisplayName("General Case: 3-4-5 Triangle") + void calculateDistance_shouldReturnCorrectEuclideanDistance_forGeneralCase() { + var p1 = new LngLatDto(0, 3.0); + var p2 = new LngLatDto(4.0, 0); + double expected = 5.0; + double actual = service.calculateDistance(p1, p2); + assertThat(actual).isCloseTo(expected, within(PRECISION)); + } + + @Test + @DisplayName("Edge Case: Points are Identical") + void calculateDistance_shouldReturnZero_whenPointsAreIdentical() { + var p1 = new LngLatDto(123.85, 983.2119); + double expected = 0.0; + double actual = service.calculateDistance(p1, p1); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("Edge Case: Longitudinal-only movement") + void calculateDistance_shouldReturnCorrectDistance_forPointsWithSameLatitude() { + var p1 = new LngLatDto(123.85, 983.2119); + var p2 = new LngLatDto(133.85, 983.2119); + double expected = 10.0; + double actual = service.calculateDistance(p1, p2); + assertThat(actual).isCloseTo(expected, within(PRECISION)); + } + + @Test + @DisplayName("Edge Case: Latitude-only movement") + void calculateDistance_shouldReturnCorrectDistance_forPointsWithSameLongitude() { + var p1 = new LngLatDto(123.85, 983.2119); + var p2 = new LngLatDto(123.85, 973.2119); + double expected = 10.0; + double actual = service.calculateDistance(p1, p2); + assertThat(actual).isCloseTo(expected, within(PRECISION)); + } + + @Test + @DisplayName("General Case: Calculate with negative Coordinates") + void calculateDistance_shouldReturnCorrectEuclideanDistance_forNegativeCoordinates() { + LngLatDto p1 = new LngLatDto(-1.0, -2.0); + LngLatDto p2 = new LngLatDto(2.0, 2.0); // lngDiff = 3, latDiff = 4 + double expected = 5.0; + double actual = service.calculateDistance(p1, p2); + assertThat(actual).isCloseTo(expected, within(PRECISION)); + } + } + + @Nested + @DisplayName("Test for isCloseTo(LngLatDto, LngLatDto) -> boolean") + class IsCloseToTests { + @Test + @DisplayName("False: Given Example For Testing") + void isCloseTo_shouldReturnFalse_givenExample() { + var p1 = new LngLatDto(-3.192473, 55.946233); + var p2 = new LngLatDto(-3.192473, 55.942617); + boolean expected = false; + boolean actual = service.isCloseTo(p1, p2); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("True: Two points are the same") + void isCloseTo_shouldReturnTrue_whenPointsAreIdentical() { + var p1 = new LngLatDto(151.86, 285.37); + boolean expected = true; + boolean actual = service.isCloseTo(p1, p1); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("True: Two points are close to each other and near threshold") + void isCloseTo_shouldReturnTrue_whenCloseAndSmallerThanThreshold() { + var p1 = new LngLatDto(0.0, 0.0); + var p2 = new LngLatDto(0.0, 0.00014); + boolean expected = true; + boolean actual = service.isCloseTo(p1, p2); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("False: Distance nears the threshold") + void isCloseTo_shouldReturnFalse_whenEqualsToThreshold() { + var p1 = new LngLatDto(0.0, 0.0); + var p2 = new LngLatDto(0.0, 0.00015); + boolean expected = false; + boolean actual = service.isCloseTo(p1, p2); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("False: Distance larger to threshold") + void isCloseTo_shouldReturnFalse_whenNotCloseAndLargerThanThreshold() { + var p1 = new LngLatDto(0.0, 0.0); + var p2 = new LngLatDto(0.0, 0.00016); + boolean expected = false; + boolean actual = service.isCloseTo(p1, p2); + assertThat(actual).isEqualTo(expected); + } + } + + @Nested + @DisplayName("Test for nextPosition(LngLatDto, double) -> LngLatDto") + class NextPositionTests { + + @Test + @DisplayName("General Case: nextPosition in East direction (0 degrees)") + void nextPosition_shouldMoveEast_forAngleZero() { + var start = new LngLatDto(0.0, 0.0); + double angle = 0; + // For 0 degrees, cos(0)=1, sin(0)=0. Move happens entirely on lng axis. + var expected = new LngLatDto(STEP, 0.0); + + var actual = service.nextPosition(start, angle); + + 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)") + void nextPosition_shouldMoveNorth_forAngle90() { + var start = new LngLatDto(0.0, 0.0); + double angle = 90; + // For 90 degrees, cos(90)=0, sin(90)=1. Move happens entirely on lat axis. + var expected = new LngLatDto(0.0, STEP); + + var actual = service.nextPosition(start, angle); + + 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)") + void nextPosition_shouldMoveWest_forAngle180() { + var start = new LngLatDto(0.0, 0.0); + double angle = 180; + // For 180 degrees, cos(180)=-1, sin(180)=0. Move happens entirely on negative lng axis. + var expected = new LngLatDto(-STEP, 0.0); + + var actual = service.nextPosition(start, angle); + + 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)") + void nextPosition_shouldMoveSouth_forAngle270() { + var start = new LngLatDto(0.0, 0.0); + double angle = 270; + // For 270 degrees, cos(270)=0, sin(270)=-1. Move happens entirely on negative lat axis. + var expected = new LngLatDto(0.0, -STEP); + + var actual = service.nextPosition(start, angle); + + 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)") + void nextPosition_shouldMoveNortheast_forAngle45() { + var start = new LngLatDto(0.0, 0.0); + double 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)); + var expected = new LngLatDto(expectedLng, expectedLat); + + 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: Angle larger than 360 should wrap around") + void nextPosition_shouldHandleAngleGreaterThan360() { + var start = new LngLatDto(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 LngLatDto(expectedLng, expectedLat); + + 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 LngLatDto(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 LngLatDto(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 RegionDto RECTANGLE_REGION = new RegionDto("rectangle", List.of(new LngLatDto(0.0, 0.0), new LngLatDto(2.0, 0.0), new LngLatDto(2.0, 2.0), new LngLatDto(0.0, 2.0), new LngLatDto(0.0, 0.0))); + + @Test + @DisplayName("General Case: Given Example for Testing") + void isInRegion_shouldReturnFalse_givenPolygonCentral() { + var position = new LngLatDto(1.234, 1.222); + var region = new RegionDto("central", List.of(new LngLatDto(-3.192473, 55.946233), new LngLatDto(-3.192473, 55.942617), new LngLatDto(-3.184319, 55.942617), new LngLatDto(-3.184319, 55.946233), new LngLatDto(-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 LngLatDto(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 LngLatDto(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 LngLatDto(2.0, 2.0); + var region = new RegionDto("hexagon", List.of(new LngLatDto(1.0, 0.0), new LngLatDto(4.0, 0.0), new LngLatDto(5.0, 2.0), new LngLatDto(4.0, 4.0), new LngLatDto(1.0, 4.0), new LngLatDto(0.0, 2.0), new LngLatDto(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 LngLatDto(0.00001, 0.00001); + var region = new RegionDto("triangle", List.of(new LngLatDto(0.0, 0.0), new LngLatDto(0.0001, 0.0), new LngLatDto(0.00005, 0.0001), new LngLatDto(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 LngLatDto(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 LngLatDto(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 LngLatDto(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 LngLatDto(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 LngLatDto(2.0, 2.0); + boolean expected = true; + boolean actual = service.checkIsInRegion(position, RECTANGLE_REGION); + assertThat(actual).isEqualTo(expected); + } + } +} \ No newline at end of file