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";
@ -60,7 +54,7 @@ public class DroneInfoService {
* *
* @param state determines the capability filtering * @param state determines the capability filtering
* @return if {@code state} is true, return ids of drones with cooling * @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) * @see io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean)
*/ */
public List<String> dronesWithCooling(boolean state) { public List<String> dronesWithCooling(boolean state) {
@ -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,261 +262,216 @@ public class GpsCalculationServiceTest {
); );
} }
@Test @Nested
@DisplayName("Edge Case: Angle larger than 360 should wrap around") @DisplayName(
void nextPosition_shouldHandleAngleGreaterThan360() { "Test for checkIsInRegion(LngLatDto, RegionDto) -> boolean"
var start = new LngLat(0.0, 0.0); )
// 405 degrees is equivalent to 45 degrees (405 % 360 = 45). class CheckIsInRegionTests {
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); public static final Region RECTANGLE_REGION = new Region(
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(
"rectangle", "rectangle",
List.of( List.of(
new LngLat(0.0, 0.0), new LngLat(0.0, 0.0),
new LngLat(2.0, 0.0), new LngLat(2.0, 0.0),
new LngLat(2.0, 2.0), new LngLat(2.0, 2.0),
new LngLat(0.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 @Test
@DisplayName("Edge Case: Vertex list is empty") @DisplayName("General Case: Given Example for Testing")
void isInRegion_shouldThrowExceptions_whenListIsEmpty() { void isInRegion_shouldReturnFalse_givenPolygonCentral() {
var position = new LngLat(2.0, 2.0); var position = new LngLat(1.234, 1.222);
var region = new Region("rectangle", List.of()); var region = new Region(
assertThatThrownBy(() -> { "central",
service.checkIsInRegion(position, region); List.of(
}) new LngLat(-3.192473, 55.946233),
.isInstanceOf(IllegalArgumentException.class) new LngLat(-3.192473, 55.942617),
.hasMessage("Region is not closed."); 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.");
}
} }
} }
} }