fix(cw1): fix sematic erros

This commit is contained in:
js0ny 2025-11-27 11:23:41 +00:00
parent 141a957a8d
commit 6795612079
12 changed files with 435 additions and 394 deletions

View file

@ -43,6 +43,10 @@ body:json {
] ]
} }
assert {
res.status: eq 200
}
settings { settings {
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View file

@ -30,6 +30,10 @@ body:json {
] ]
} }
assert {
res.status: eq 200
}
settings { settings {
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View file

@ -26,6 +26,10 @@ body:json {
] ]
} }
assert {
res.status: eq 200
}
settings { settings {
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0

View file

@ -1,5 +1,6 @@
package io.github.js0ny.ilp_coursework.controller; 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.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region; import io.github.js0ny.ilp_coursework.data.common.Region;
import io.github.js0ny.ilp_coursework.data.request.DistanceRequest; import io.github.js0ny.ilp_coursework.data.request.DistanceRequest;
@ -79,7 +80,7 @@ public class ApiController {
@PostMapping("/nextPosition") @PostMapping("/nextPosition")
public LngLat getNextPosition(@RequestBody MovementRequest request) { public LngLat getNextPosition(@RequestBody MovementRequest request) {
LngLat start = request.start(); LngLat start = request.start();
double angle = request.angle(); Angle angle = new Angle(request.angle());
return gpsService.nextPosition(start, angle); return gpsService.nextPosition(start, angle);
} }

View file

@ -0,0 +1,4 @@
package io.github.js0ny.ilp_coursework.controller;
public class GeoJsonDataController {
}

View file

@ -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);
}
}

View file

@ -8,6 +8,20 @@ package io.github.js0ny.ilp_coursework.data.common;
* @param lat latitude of the coordinate/point * @param lat latitude of the coordinate/point
*/ */
public record LngLat(double lng, double lat) { 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) { public LngLat(LngLatAlt coord) {
this(coord.lng(), coord.lat()); this(coord.lng(), coord.lat());
} }

View file

@ -1,11 +1,14 @@
package io.github.js0ny.ilp_coursework.data.common; 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 io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
/** /**
* Represents the data transfer object for a region definition * Represents the data transfer object for a region definition
* <p> * <p>
@ -48,4 +51,33 @@ public record Region(String name, List<LngLat> vertices) {
LngLat last = vertices.getLast(); LngLat last = vertices.getLast();
return Objects.equals(last, first); return Objects.equals(last, first);
} }
public Map<String, Object> toGeoJson() {
try {
ObjectMapper mapper = new ObjectMapper();
List<List<Double>> 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);
}
}
} }

View file

@ -1,19 +1,14 @@
package io.github.js0ny.ilp_coursework.service; package io.github.js0ny.ilp_coursework.service;
import static io.github.js0ny.ilp_coursework.util.AttrComparator.isValueMatched; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.LngLat; import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
import io.github.js0ny.ilp_coursework.data.external.Drone; 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.RestrictedArea;
import io.github.js0ny.ilp_coursework.data.external.ServicePoint; import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones; import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones;
import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest; import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
import io.github.js0ny.ilp_coursework.data.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.net.URI;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.time.LocalDate; import java.time.LocalDate;
@ -28,7 +23,6 @@ import org.springframework.web.client.RestTemplate;
public class DroneInfoService { public class DroneInfoService {
private final String baseUrl; private final String baseUrl;
private final String dronesEndpoint = "drones";
private final String dronesForServicePointsEndpoint = private final String dronesForServicePointsEndpoint =
"drones-for-service-points"; "drones-for-service-points";
public static final String servicePointsEndpoint = "service-points"; public static final String servicePointsEndpoint = "service-points";
@ -96,10 +90,6 @@ public class DroneInfoService {
public Drone droneDetail(String id) { public Drone droneDetail(String id) {
List<Drone> drones = fetchAllDrones(); List<Drone> drones = fetchAllDrones();
if (drones == null) {
throw new NullPointerException("drone cannot be found");
}
for (var drone : drones) { for (var drone : drones) {
if (drone.id().equals(id)) { if (drone.id().equals(id)) {
return drone; return drone;
@ -119,16 +109,12 @@ public class DroneInfoService {
* Associated service method with * Associated service method with
* *
* @param rec array of medical dispatch records * @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 * @see io.github.js0ny.ilp_coursework.controller.DroneController#queryAvailableDrones
*/ */
public List<String> dronesMatchesRequirements(MedDispatchRecRequest[] rec) { public List<String> dronesMatchesRequirements(MedDispatchRecRequest[] rec) {
List<Drone> drones = fetchAllDrones(); List<Drone> drones = fetchAllDrones();
if (drones == null) {
return new ArrayList<>();
}
if (rec == null || rec.length == 0) { if (rec == null || rec.length == 0) {
return drones return drones
.stream() .stream()
@ -146,7 +132,7 @@ public class DroneInfoService {
.filter(d -> .filter(d ->
Arrays.stream(rec) Arrays.stream(rec)
.filter(r -> r != null && r.requirements() != null) .filter(r -> r != null && r.requirements() != null)
.allMatch(r -> meetsRequirement(d, r)) .allMatch(r -> droneMatchesRequirement(d, r))
) )
.map(Drone::id) .map(Drone::id)
.collect(Collectors.toList()); .collect(Collectors.toList());
@ -162,7 +148,10 @@ public class DroneInfoService {
* is invalid (capacity and id cannot be null * is invalid (capacity and id cannot be null
* in {@code MedDispathRecDto}) * in {@code MedDispathRecDto})
*/ */
public boolean meetsRequirement(Drone drone, MedDispatchRecRequest record) { public boolean droneMatchesRequirement(
Drone drone,
MedDispatchRecRequest record
) {
var requirements = record.requirements(); var requirements = record.requirements();
if (requirements == null) { if (requirements == null) {
throw new IllegalArgumentException("requirements cannot be null"); throw new IllegalArgumentException("requirements cannot be null");
@ -184,7 +173,8 @@ public class DroneInfoService {
boolean requiredHeating = requirements.heating(); boolean requiredHeating = requirements.heating();
// Case 1: required is null: We don't care about it // 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 // Case 3: capability is true: Then always matches
// See: https://piazza.com/class/me9vp64lfgf4sn/post/100 // See: https://piazza.com/class/me9vp64lfgf4sn/post/100
boolean matchesCooling = !requiredCooling || capability.cooling(); boolean matchesCooling = !requiredCooling || capability.cooling();
@ -197,7 +187,8 @@ public class DroneInfoService {
matchesHeating && matchesHeating &&
checkAvailability(drone.id(), record) 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; return null;
} }
// private Set<LngLat> parseObstacles() { public List<Drone> fetchAllDrones() {
// URI restrictedAreasUrl = URI.create(baseUrl).resolve( String dronesEndpoint = "drones";
// restrictedAreasEndpoint
// );
//
// RestrictedArea[] restrictedAreas = restTemplate.getForObject(
// restrictedAreasUrl,
// RestrictedArea[].class
// );
//
// assert restrictedAreas != null;
// Set<LngLat> 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<RestrictedArea> restrictedAreas = fetchRestrictedAreas();
// List<LngLat> totalPath = new ArrayList<>();
// List<Delivery> 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<LngLat> shortestPath = null;
// for (var d : possibleDrones) {
// var start = queryServicePointLocationByDroneId(d.id());
// List<LngLat> 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<Drone> fetchAllDrones() {
URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint); URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class); Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class);
assert drones != null;
return Arrays.asList(drones); return Arrays.asList(drones);
} }
private List<RestrictedArea> fetchRestrictedAreas() { public List<RestrictedArea> fetchRestrictedAreas() {
URI restrictedUrl = URI.create(baseUrl).resolve( URI restrictedUrl = URI.create(baseUrl).resolve(
restrictedAreasEndpoint restrictedAreasEndpoint
); );
@ -350,13 +284,22 @@ public class DroneInfoService {
RestrictedArea[].class RestrictedArea[].class
); );
assert restrictedAreas != null; assert restrictedAreas != null;
List<RestrictedArea> restrictedAreaList = Arrays.asList( return Arrays.asList(restrictedAreas);
restrictedAreas
);
return restrictedAreaList;
} }
private List<ServicePoint> fetchServicePoints() { public List<String> 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<ServicePoint> fetchServicePoints() {
URI servicePointUrl = URI.create(baseUrl).resolve( URI servicePointUrl = URI.create(baseUrl).resolve(
servicePointsEndpoint servicePointsEndpoint
); );
@ -365,11 +308,10 @@ public class DroneInfoService {
ServicePoint[].class ServicePoint[].class
); );
assert servicePoints != null; assert servicePoints != null;
List<ServicePoint> servicePointList = Arrays.asList(servicePoints); return Arrays.asList(servicePoints);
return servicePointList;
} }
private List<ServicePointDrones> fetchDronesForServicePoints() { public List<ServicePointDrones> fetchDronesForServicePoints() {
URI servicePointDronesUrl = URI.create(baseUrl).resolve( URI servicePointDronesUrl = URI.create(baseUrl).resolve(
dronesForServicePointsEndpoint dronesForServicePointsEndpoint
); );
@ -378,43 +320,6 @@ public class DroneInfoService {
ServicePointDrones[].class ServicePointDrones[].class
); );
assert servicePointDrones != null; assert servicePointDrones != null;
List<ServicePointDrones> servicePointDronesList = Arrays.asList( return Arrays.asList(servicePointDrones);
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;
} }
} }

View file

@ -1,5 +1,6 @@
package io.github.js0ny.ilp_coursework.service; 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.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region; import io.github.js0ny.ilp_coursework.data.common.Region;
import io.github.js0ny.ilp_coursework.data.request.DistanceRequest; import io.github.js0ny.ilp_coursework.data.request.DistanceRequest;
@ -84,8 +85,8 @@ public class GpsCalculationService {
* @see #STEP * @see #STEP
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getNextPosition(MovementRequest) * @see io.github.js0ny.ilp_coursework.controller.ApiController#getNextPosition(MovementRequest)
*/ */
public LngLat nextPosition(LngLat start, double angle) { public LngLat nextPosition(LngLat start, Angle angle) {
double rad = Math.toRadians(angle); // Convert to radian for Java triangle function calculation double rad = angle.toRadians();
double newLng = Math.cos(rad) * STEP + start.lng(); double newLng = Math.cos(rad) * STEP + start.lng();
double newLat = Math.sin(rad) * STEP + start.lat(); double newLat = Math.sin(rad) * STEP + start.lat();
return new LngLat(newLng, newLat); return new LngLat(newLng, newLat);

View file

@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.Angle;
import io.github.js0ny.ilp_coursework.data.common.LngLat; import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region; import io.github.js0ny.ilp_coursework.data.common.Region;
import io.github.js0ny.ilp_coursework.data.request.DistanceRequest; import io.github.js0ny.ilp_coursework.data.request.DistanceRequest;
@ -101,6 +102,25 @@ public class ApiControllerTest {
) )
.andExpect(status().isBadRequest()); .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 @Nested
@ -146,6 +166,25 @@ public class ApiControllerTest {
) )
.andExpect(status().isBadRequest()); .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 @Nested
@ -162,7 +201,7 @@ public class ApiControllerTest {
LngLat p = new LngLat(0, 0); LngLat p = new LngLat(0, 0);
var req = new MovementRequest(p, 0); var req = new MovementRequest(p, 0);
when( when(
service.nextPosition(any(LngLat.class), anyDouble()) service.nextPosition(any(LngLat.class), any(Angle.class))
).thenReturn(expected); ).thenReturn(expected);
var mock = mockMvc.perform( var mock = mockMvc.perform(
post(endpoint) post(endpoint)
@ -187,7 +226,7 @@ public class ApiControllerTest {
"angle": 180 "angle": 180
} }
"""; """;
when(service.nextPosition(isNull(), anyDouble())).thenThrow( when(service.nextPosition(isNull(), any(Angle.class))).thenThrow(
new NullPointerException() new NullPointerException()
); );
mockMvc mockMvc
@ -198,6 +237,32 @@ public class ApiControllerTest {
) )
.andExpect(MockMvcResultMatchers.status().isBadRequest()); .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 @Nested

View file

@ -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.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.within; 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.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region; import io.github.js0ny.ilp_coursework.data.common.Region;
import java.util.List; import java.util.List;
@ -52,7 +53,7 @@ public class GpsCalculationServiceTest {
@Test @Test
@DisplayName("Edge Case: Points are Identical") @DisplayName("Edge Case: Points are Identical")
void calculateDistance_shouldReturnZero_whenPointsAreIdentical() { 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 expected = 0.0;
double actual = service.calculateDistance(p1, p1); double actual = service.calculateDistance(p1, p1);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
@ -61,8 +62,8 @@ public class GpsCalculationServiceTest {
@Test @Test
@DisplayName("Edge Case: Longitudinal-only movement") @DisplayName("Edge Case: Longitudinal-only movement")
void calculateDistance_shouldReturnCorrectDistance_forPointsWithSameLatitude() { void calculateDistance_shouldReturnCorrectDistance_forPointsWithSameLatitude() {
var p1 = new LngLat(123.85, 983.2119); var p1 = new LngLat(23.85, 83.2119);
var p2 = new LngLat(133.85, 983.2119); var p2 = new LngLat(33.85, 83.2119);
double expected = 10.0; double expected = 10.0;
double actual = service.calculateDistance(p1, p2); double actual = service.calculateDistance(p1, p2);
assertThat(actual).isCloseTo(expected, within(PRECISION)); assertThat(actual).isCloseTo(expected, within(PRECISION));
@ -71,8 +72,8 @@ public class GpsCalculationServiceTest {
@Test @Test
@DisplayName("Edge Case: Latitude-only movement") @DisplayName("Edge Case: Latitude-only movement")
void calculateDistance_shouldReturnCorrectDistance_forPointsWithSameLongitude() { void calculateDistance_shouldReturnCorrectDistance_forPointsWithSameLongitude() {
var p1 = new LngLat(123.85, 983.2119); var p1 = new LngLat(123.85, 68.2119);
var p2 = new LngLat(123.85, 973.2119); var p2 = new LngLat(123.85, 58.2119);
double expected = 10.0; double expected = 10.0;
double actual = service.calculateDistance(p1, p2); double actual = service.calculateDistance(p1, p2);
assertThat(actual).isCloseTo(expected, within(PRECISION)); assertThat(actual).isCloseTo(expected, within(PRECISION));
@ -106,7 +107,7 @@ public class GpsCalculationServiceTest {
@Test @Test
@DisplayName("True: Two points are the same") @DisplayName("True: Two points are the same")
void isCloseTo_shouldReturnTrue_whenPointsAreIdentical() { void isCloseTo_shouldReturnTrue_whenPointsAreIdentical() {
var p1 = new LngLat(151.86, 285.37); var p1 = new LngLat(15.86, 28.37);
boolean expected = true; boolean expected = true;
boolean actual = service.isCloseTo(p1, p1); boolean actual = service.isCloseTo(p1, p1);
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
@ -153,7 +154,7 @@ public class GpsCalculationServiceTest {
@DisplayName("General Case: nextPosition in East direction (0 degrees)") @DisplayName("General Case: nextPosition in East direction (0 degrees)")
void nextPosition_shouldMoveEast_forAngleZero() { void nextPosition_shouldMoveEast_forAngleZero() {
var start = new LngLat(0.0, 0.0); 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. // For 0 degrees, cos(0)=1, sin(0)=0. Move happens entirely on lng axis.
var expected = new LngLat(STEP, 0.0); var expected = new LngLat(STEP, 0.0);
@ -175,7 +176,7 @@ public class GpsCalculationServiceTest {
) )
void nextPosition_shouldMoveNorth_forAngle90() { void nextPosition_shouldMoveNorth_forAngle90() {
var start = new LngLat(0.0, 0.0); 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. // For 90 degrees, cos(90)=0, sin(90)=1. Move happens entirely on lat axis.
var expected = new LngLat(0.0, STEP); var expected = new LngLat(0.0, STEP);
@ -197,7 +198,7 @@ public class GpsCalculationServiceTest {
) )
void nextPosition_shouldMoveWest_forAngle180() { void nextPosition_shouldMoveWest_forAngle180() {
var start = new LngLat(0.0, 0.0); 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 // For 180 degrees, cos(180)=-1, sin(180)=0. Move happens entirely on negative
// lng axis. // lng axis.
var expected = new LngLat(-STEP, 0.0); var expected = new LngLat(-STEP, 0.0);
@ -220,7 +221,7 @@ public class GpsCalculationServiceTest {
) )
void nextPosition_shouldMoveSouth_forAngle270() { void nextPosition_shouldMoveSouth_forAngle270() {
var start = new LngLat(0.0, 0.0); 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 // For 270 degrees, cos(270)=0, sin(270)=-1. Move happens entirely on negative
// lat axis. // lat axis.
var expected = new LngLat(0.0, -STEP); var expected = new LngLat(0.0, -STEP);
@ -243,10 +244,10 @@ public class GpsCalculationServiceTest {
) )
void nextPosition_shouldMoveNortheast_forAngle45() { void nextPosition_shouldMoveNortheast_forAngle45() {
var start = new LngLat(0.0, 0.0); var start = new LngLat(0.0, 0.0);
double angle = 45; Angle angle = new Angle(45);
// Δlng = step * cos(45°), Δlat = step * sin(45°) // Δlng = step * cos(45°), Δlat = step * sin(45°)
double expectedLng = STEP * Math.cos(Math.toRadians(angle)); double expectedLng = STEP * Math.cos(angle.toRadians());
double expectedLat = STEP * Math.sin(Math.toRadians(angle)); double expectedLat = STEP * Math.sin(angle.toRadians());
var expected = new LngLat(expectedLng, expectedLat); var expected = new LngLat(expectedLng, expectedLat);
var actual = service.nextPosition(start, angle); var actual = service.nextPosition(start, angle);
@ -261,56 +262,10 @@ 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);
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 @Nested
@DisplayName("Test for checkIsInRegion(LngLatDto, RegionDto) -> boolean") @DisplayName(
"Test for checkIsInRegion(LngLatDto, RegionDto) -> boolean"
)
class CheckIsInRegionTests { class CheckIsInRegionTests {
public static final Region RECTANGLE_REGION = new Region( public static final Region RECTANGLE_REGION = new Region(
@ -518,4 +473,5 @@ public class GpsCalculationServiceTest {
.hasMessage("Region is not closed."); .hasMessage("Region is not closed.");
} }
} }
}
} }