chore: rebuild directory structure

This commit is contained in:
js0ny 2025-11-28 07:53:04 +00:00
parent d3ce236672
commit dbf71443c7
91 changed files with 6683 additions and 1 deletions

View file

@ -0,0 +1,9 @@
FROM maven:3.9.9-amazoncorretto-21-debian AS build
WORKDIR /app
COPY ./build/libs/ilp-coursework-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

57
ilp-rest-service/Makefile Normal file
View file

@ -0,0 +1,57 @@
# Makefile
# --- Variables ---
IMAGE_NAME := ilp-coursework
IMAGE_TAG := 0.2
FULL_IMAGE_NAME := ${IMAGE_NAME}:${IMAGE_TAG}
CONTAINER_NAME := ilp-coursework-app
SUBMISSION_FILE := ilp_submission_image.tar
# --- Targets ---
# Default command
.PHONY: all
all: save
.PHONY: build
build:
./gradlew build
.PHONY: docker-build
docker-build: build
docker build -t ${FULL_IMAGE_NAME} .
# Run the container in detached mode.
# It will first stop and remove any existing container with the same name.
.PHONY: run
run: docker-build
@echo "Stopping and removing old container if it exists..."
# -docker stop ${CONTAINER_NAME} > /dev/null 2>&1 || true
# -docker rm ${CONTAINER_NAME} > /dev/null 2>&1 || true
docker-compose stop
@echo "Starting new container '${CONTAINER_NAME}' on http://localhost:8080"
docker-compose up -d
.PHONY: stop
stop:
@echo "Stopping and removing container: ${CONTAINER_NAME}"
# -docker stop ${CONTAINER_NAME} > /dev/null 2>&1 || true
# -docker rm ${CONTAINER_NAME} > /dev/null 2>&1 || true
docker-compose stop > /dev/null 2>&1 || true
# --- Submission Target ---
.PHONY: save
save: docker-build
docker save -o ${SUBMISSION_FILE} ${FULL_IMAGE_NAME}
@echo "Submission file '${SUBMISSION_FILE}' created successfully."
# --- Utility Target ---
.PHONY: clean
clean:
./gradlew clean
-rm -f ${SUBMISSION_FILE}

View file

@ -0,0 +1,31 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.6'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'io.github.js0ny'
version = '0.0.1-SNAPSHOT'
description = 'Demo project for Spring Boot'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
tasks.named('test') {
useJUnitPlatform()
}

View file

@ -0,0 +1,8 @@
# compose.yaml
services:
app:
build: .
image: ilp-coursework:0.2
container_name: ilp-coursework-app
ports:
- "8080:8080"

27
ilp-rest-service/flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1762977756,
"narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View file

@ -0,0 +1,43 @@
{
description = "Flake for environment building ILP CW";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = {
self,
nixpkgs,
}: {
devShells =
nixpkgs.lib.genAttrs [
"x86_64-linux"
"aarch64-darwin"
] (system: let
pkgs = import nixpkgs {
inherit system;
};
in {
default = pkgs.mkShell {
buildInputs = with pkgs; [
vscode-langservers-extracted
jdt-language-server
jless
jdk21
gradle
httpie
docker
docker-compose
newman
gron
fx
google-java-format
];
shellHook = ''
export JAVA_HOME=${pkgs.jdk21}
echo "Java: $(java --version | head -n 1)"
'';
};
});
};
}

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
ilp-rest-service/gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
ilp-rest-service/gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,24 @@
meta {
name: [Sematic Error] distanceTo
type: http
seq: 1
}
post {
url: {{API_BASE}}/distanceTo
body: json
auth: inherit
}
body:json {
{ "position1": { "lng": -300.192473, "lat": 550.946233 }, "position2": { "lng": -3202.192473, "lat": 5533.942617 } }
}
assert {
res.status: eq 400
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,24 @@
meta {
name: [Sematic Error] isCloseTo
type: http
seq: 2
}
post {
url: {{API_BASE}}/isCloseTo
body: json
auth: inherit
}
body:json {
{ "position1": { "lng": -3004.192473, "lat": 550.946233 }, "position2": { "lng": -390.192473, "lat": 551.942617 } }
}
assert {
res.status: eq 400
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,24 @@
meta {
name: [Sematic Error] nextPosition
type: http
seq: 3
}
post {
url: {{API_BASE}}/nextPosition
body: json
auth: inherit
}
body:json {
{ "start": { "lng": -3.192473, "lat": 55.946233 }, "angle": 900 }
}
assert {
res.status: eq 400
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,8 @@
meta {
name: CW1 Feedback API
seq: 6
}
auth {
mode: inherit
}

View file

@ -0,0 +1,29 @@
meta {
name: drone 1 details
type: http
seq: 1
}
get {
url: {{API_BASE}}/droneDetails/1
body: none
auth: inherit
}
assert {
res.body.id: eq "1"
res.body.capability.capacity: eq 4.0
res.body.capability.heating: eq true
}
tests {
test("Status code is 200", function() {
expect(res.status).to.equal(200);
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,29 @@
meta {
name: drone 10 details
type: http
seq: 2
}
get {
url: {{API_BASE}}/droneDetails/10
body: none
auth: inherit
}
assert {
res.body.id: eq "10"
res.body.capability.capacity: eq 12.0
res.body.capability.heating: eq false
}
tests {
test("Status code is 200", function() {
expect(res.status).to.equal(200);
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,23 @@
meta {
name: drone 11 details
type: http
seq: 3
}
get {
url: {{API_BASE}}/droneDetails/11
body: none
auth: inherit
}
tests {
test("Status code is 404", function() {
expect(res.status).to.equal(404);
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,23 @@
meta {
name: drone NaN details
type: http
seq: 4
}
get {
url: {{API_BASE}}/droneDetails/droneNaN
body: none
auth: inherit
}
tests {
test("Status code is 404", function() {
expect(res.status).to.equal(404);
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,8 @@
meta {
name: [GET] droneDetails
seq: 2
}
auth {
mode: inherit
}

View file

@ -0,0 +1,31 @@
meta {
name: dronesWithCooling -> false
type: http
seq: 1
}
get {
url: {{API_BASE}}/dronesWithCooling/false
body: none
auth: inherit
}
tests {
test("Status code is 200", function() {
expect(res.status).to.equal(200);
});
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
test("Array is not empty and contains Strings", function() {
const data = res.getBody();
expect(data[0]).to.be.a('string'); // data should be in string
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,24 @@
meta {
name: dronesWithCooling -> illegal
type: http
seq: 1
}
get {
url: {{API_BASE}}/dronesWithCooling/illegal
body: none
auth: inherit
}
tests {
test("Status code is 400", function() {
expect(res.status).to.equal(400);
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,31 @@
meta {
name: dronesWithCooling -> true
type: http
seq: 1
}
get {
url: {{API_BASE}}/dronesWithCooling/true
body: none
auth: inherit
}
tests {
test("Status code is 200", function() {
expect(res.status).to.equal(200);
});
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
test("Array is not empty and contains String", function() {
const data = res.getBody();
expect(data[0]).to.be.a('string'); // data should be in String
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,8 @@
meta {
name: [GET] dronesWithCooling
seq: 1
}
auth {
mode: inherit
}

View file

@ -0,0 +1,16 @@
meta {
name: null should return 200
type: http
seq: 4
}
get {
url: {{API_BASE}}/dronesWithCooling/null
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,35 @@
meta {
name: capacity = 4
type: http
seq: 3
}
get {
url: {{API_BASE}}/queryAsPath/capacity/4
body: none
auth: inherit
}
assert {
res.body: length 2
}
tests {
test("Status code is 200", function() {
expect(res.status).to.equal(200);
});
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
test("Array is not empty and contains Strings", function() {
const data = res.getBody();
expect(data[0]).to.be.a('string'); // data should be in string
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,35 @@
meta {
name: capacity = 8.0
type: http
seq: 2
}
get {
url: {{API_BASE}}/queryAsPath/capacity/8.0
body: none
auth: inherit
}
assert {
res.body: length 4
}
tests {
test("Status code is 200", function() {
expect(res.status).to.equal(200);
});
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
test("Array is not empty and contains Strings", function() {
const data = res.getBody();
expect(data[0]).to.be.a('string'); // data should be in string
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,35 @@
meta {
name: capacity = 8
type: http
seq: 1
}
get {
url: {{API_BASE}}/queryAsPath/capacity/8
body: none
auth: inherit
}
assert {
res.body: length 4
}
tests {
test("Status code is 200", function() {
expect(res.status).to.equal(200);
});
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
test("Array is not empty and contains Strings", function() {
const data = res.getBody();
expect(data[0]).to.be.a('string'); // data should be in string
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,35 @@
meta {
name: cooling = false
type: http
seq: 5
}
get {
url: {{API_BASE}}/queryAsPath/cooling/false
body: none
auth: inherit
}
assert {
res.body: length 6
}
tests {
test("Status code is 200", function() {
expect(res.status).to.equal(200);
});
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
test("Array is not empty and contains Strings", function() {
const data = res.getBody();
expect(data[0]).to.be.a('string'); // data should be in string
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,8 @@
meta {
name: [GET] queryAsPath
seq: 3
}
auth {
mode: inherit
}

View file

@ -0,0 +1,35 @@
meta {
name: heating = true
type: http
seq: 4
}
get {
url: {{API_BASE}}/queryAsPath/heating/true
body: none
auth: inherit
}
assert {
res.body: length 7
}
tests {
test("Status code is 200", function() {
expect(res.status).to.equal(200);
});
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
test("Array is not empty and contains Strings", function() {
const data = res.getBody();
expect(data[0]).to.be.a('string'); // data should be in string
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,27 @@
meta {
name: invalid = null
type: http
seq: 7
}
get {
url: {{API_BASE}}/queryAsPath/invalid/null
body: none
auth: inherit
}
assert {
res.status: eq 200
}
tests {
test("Status code is 200", function() {
expect(res.status).to.equal(200);
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,36 @@
meta {
name: maxMoves = 1000
type: http
seq: 6
}
get {
url: {{API_BASE}}/queryAsPath/maxMoves/1000
body: none
auth: inherit
}
assert {
res.body: length 4
res.status : eq 200
}
tests {
test("Status code is 200", function() {
expect(res.status).to.equal(200);
});
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
test("Array is not empty and contains Strings", function() {
const data = res.getBody();
expect(data[0]).to.be.a('string'); // data should be in string
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,39 @@
meta {
name: Bypass Bayes Centre
type: http
seq: 10
}
post {
url: {{API_BASE}}/calcDeliveryPathAsGeoJson
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.187543601324762,
"lat":55.94524469728245
}
}
]
}
assert {
res.status: eq 200
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,39 @@
meta {
name: Bypass RestrictedArea
type: http
seq: 9
}
post {
url: {{API_BASE}}/calcDeliveryPathAsGeoJson
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.1878363275002357,
"lat":55.94302147870653
}
}
]
}
assert {
res.status: eq 200
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,39 @@
meta {
name: From Appleton Tower
type: http
seq: 6
}
post {
url: {{API_BASE}}/calcDeliveryPathAsGeoJson
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.186979243145231,
"lat":55.94230336036327
}
}
]
}
assert {
res.status: eq 200
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,39 @@
meta {
name: Simple: Path
type: http
seq: 4
}
post {
url: {{API_BASE}}/calcDeliveryPath
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.17,
"lat": 55.9
}
}
]
}
assert {
res.status: eq 200
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,39 @@
meta {
name: Simple
type: http
seq: 4
}
post {
url: {{API_BASE}}/calcDeliveryPathAsGeoJson
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.17,
"lat": 55.9
}
}
]
}
assert {
res.status: eq 200
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,53 @@
meta {
name: Two Points from two service points
type: http
seq: 8
}
post {
url: {{API_BASE}}/calcDeliveryPathAsGeoJson
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.1878363275002357,
"lat":55.94302147870653
}
},
{
"id": 456,
"date": "2025-12-25",
"time": "11:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.187986659939753,
"lat": 55.97157210129231
}
}
]
}
assert {
res.status: eq 200
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,39 @@
meta {
name: [400] DeliveryPoint in RestrictedArea
type: http
seq: 7
}
post {
url: {{API_BASE}}/calcDeliveryPathAsGeoJson
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.188779987281663,
"lat": 55.943652825016414
}
}
]
}
assert {
res.status: eq 400
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,8 @@
meta {
name: [POST] calcDeliveryPath(AsGeoJson)
seq: 7
}
auth {
mode: inherit
}

View file

@ -0,0 +1,44 @@
meta {
name: GT LT -contradict
type: http
seq: 3
}
post {
url: {{API_BASE}}/query
body: json
auth: inherit
}
body:json {
[
{
"attribute": "capacity",
"operator": ">",
"value": "8"
},
{
"attribute": "capacity",
"operator": "<",
"value": "8"
}
]
}
assert {
res.status: eq 200
res.body: length 0
}
tests {
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,53 @@
meta {
name: GT LT EQ
type: http
seq: 4
}
post {
url: {{API_BASE}}/query
body: json
auth: inherit
}
body:json {
[
{
"attribute": "capacity",
"operator": ">",
"value": "8"
},
{
"attribute": "maxMoves",
"operator": "<",
"value": "2000"
},
{
"attribute": "cooling",
"operator": "=",
"value": "true"
}
]
}
assert {
res.status: eq 200
res.body: length 1
}
tests {
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
test("Array is not empty and contains Strings", function() {
const data = res.getBody();
expect(data[0]).to.be.a('string'); // data should be in string
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,48 @@
meta {
name: GT LT
type: http
seq: 2
}
post {
url: {{API_BASE}}/query
body: json
auth: inherit
}
body:json {
[
{
"attribute": "capacity",
"operator": ">",
"value": "8"
},
{
"attribute": "maxMoves",
"operator": "<",
"value": "2000"
}
]
}
assert {
res.status: eq 200
res.body: length 2
}
tests {
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
test("Array is not empty and contains Strings", function() {
const data = res.getBody();
expect(data[0]).to.be.a('string'); // data should be in string
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,8 @@
meta {
name: [POST] query
seq: 4
}
auth {
mode: inherit
}

View file

@ -0,0 +1,53 @@
meta {
name: three eqs
type: http
seq: 1
}
post {
url: {{API_BASE}}/query
body: json
auth: inherit
}
body:json {
[
{
"attribute": "capacity",
"operator": "=",
"value": "20"
},
{
"attribute": "heating",
"operator": "=",
"value": "false"
},
{
"attribute": "cooling",
"operator": "=",
"value": "true"
}
]
}
assert {
res.status: eq 200
res.body: length 1
}
tests {
test("Response body is a JSON array", function() {
expect(res.getBody()).to.be.an('array');
});
test("Array is not empty and contains Strings", function() {
const data = res.getBody();
expect(data[0]).to.be.a('string'); // data should be in string
});
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,53 @@
meta {
name: Complex
type: http
seq: 3
}
post {
url: {{API_BASE}}/queryAvailableDrones
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.00,
"lat": 55.121
}
},
{
"id": 456,
"date": "2025-12-25",
"time": "11:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.00,
"lat": 55.121
}
}
]
}
assert {
res.status: eq 200
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,40 @@
meta {
name: Example
type: http
seq: 1
}
post {
url: {{API_BASE}}/queryAvailableDrones
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"cooling": false,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.00,
"lat": 55.121
}
}
]
}
assert {
res.status: eq 200
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,36 @@
meta {
name: Treat Null as False (Cooling)
type: http
seq: 2
}
post {
url: {{API_BASE}}/queryAvailableDrones
body: json
auth: inherit
}
body:json {
[
{
"id": 123,
"date": "2025-12-22",
"time": "14:30",
"requirements": {
"capacity": 0.75,
"heating": true,
"maxCost": 13.5
},
"delivery": null
}
]
}
assert {
res.status: eq 200
}
settings {
encodeUrl: true
timeout: 0
}

View file

@ -0,0 +1,8 @@
meta {
name: [POST] queryAvailableDrones
seq: 5
}
auth {
mode: inherit
}

View file

@ -0,0 +1,9 @@
{
"version": "1",
"name": "ILP CW API Collection",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View file

@ -0,0 +1,4 @@
vars:pre-request {
BASE_URL: http://localhost:8080
API_BASE: {{BASE_URL}}/api/v1
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
rootProject.name = 'ilp-coursework'

View file

@ -0,0 +1,12 @@
package io.github.js0ny.ilp_coursework;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class IlpCourseworkApplication {
public static void main(String[] args) {
SpringApplication.run(IlpCourseworkApplication.class, args);
}
}

View file

@ -0,0 +1,104 @@
package io.github.js0ny.ilp_coursework.controller;
import io.github.js0ny.ilp_coursework.data.common.Angle;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
import io.github.js0ny.ilp_coursework.data.request.DistanceRequest;
import io.github.js0ny.ilp_coursework.data.request.MovementRequest;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import io.github.js0ny.ilp_coursework.service.GpsCalculationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Main REST Controller for the ILP Coursework 1 application.
*
* <p>This class handles incoming HTTP requests for the API under {@code /api/v1} path (defined in
* CW1) This is responsible for mapping requests to the appropriate service method and returning the
* results as responses. The business logic is delegated to {@link GpsCalculationService}
*/
@RestController
@RequestMapping("/api/v1")
public class ApiController {
private final GpsCalculationService gpsService;
/**
* Constructor of the {@code ApiController} with the business logic dependency {@code
* GpsCalculationService}
*
* @param gpsService The service component that contains all business logic, injected by
* Spring's DI.
*/
public ApiController(GpsCalculationService gpsService) {
this.gpsService = gpsService;
}
/**
* Handles GET requests to retrieve the student's Unique ID
*
* @return A string representing the student ID starting with s
*/
@GetMapping("/uid")
public String getUid() {
return "s2522255";
}
/**
* Handles POST requests to get the distance between two positions
*
* @param request A {@link DistanceRequest} containing the two coordinates
* @return A {@code double} representing the calculated distance
*/
@PostMapping("/distanceTo")
public double getDistance(@RequestBody DistanceRequest request) {
LngLat position1 = request.position1();
LngLat position2 = request.position2();
return gpsService.calculateDistance(position1, position2);
}
/**
* Handles POST requests to check if the two coordinates are close to each other
*
* @param request A {@link DistanceRequest} containing the two coordinates
* @return {@code true} if the distance is less than the predefined threshold, {@code false}
* otherwise
*/
@PostMapping("/isCloseTo")
public boolean getIsCloseTo(@RequestBody DistanceRequest request) {
LngLat position1 = request.position1();
LngLat position2 = request.position2();
return gpsService.isCloseTo(position1, position2);
}
/**
* Handles POST requests to get the next position after an angle of movement
*
* @param request A {@link MovementRequest} containing the start coordinate and angle of the
* movement.
* @return A {@link LngLat} representing the destination
*/
@PostMapping("/nextPosition")
public LngLat getNextPosition(@RequestBody MovementRequest request) {
LngLat start = request.start();
Angle angle = new Angle(request.angle());
return gpsService.nextPosition(start, angle);
}
/**
* Handles POST requests to check if a point is inside a given region
*
* @param request A {@link RegionCheckRequest} containing the coordinate and the region
* @return {@code true} if the coordinate is inside the region, {@code false} otherwise
*/
@PostMapping("/isInRegion")
public boolean getIsInRegion(@RequestBody RegionCheckRequest request) {
LngLat position = request.position();
Region region = request.region();
return gpsService.checkIsInRegion(position, region);
}
}

View file

@ -0,0 +1,111 @@
package io.github.js0ny.ilp_coursework.controller;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
import io.github.js0ny.ilp_coursework.service.DroneAttrComparatorService;
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import io.github.js0ny.ilp_coursework.service.PathFinderService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* Main Rest Controller for the ILP Coursework 2 application.
*
* <p>This class handles incoming HTTP requests for the API under {@code /api/v1} path (defined in
* CW2) The business logic is delegated to {@link DroneInfoService}
*/
@RestController
@RequestMapping("/api/v1")
public class DroneController {
private final DroneInfoService droneInfoService;
private final DroneAttrComparatorService droneAttrComparatorService;
private final PathFinderService pathFinderService;
/**
* Constructor of the {@code DroneController} with the business logic dependency {@code
* DroneInfoService}
*
* <p>We handle the {@code baseUrl} here. Use a predefined URL if the environment variable
* {@code ILP_ENDPOINT} is not given.
*
* @param droneService The service component that contains all business logic
*/
public DroneController(
DroneInfoService droneService,
DroneAttrComparatorService droneAttrComparatorService,
PathFinderService pathFinderService) {
this.droneInfoService = droneService;
this.droneAttrComparatorService = droneAttrComparatorService;
this.pathFinderService = pathFinderService;
}
/**
* Handles GET requests to retrieve an array of drones (identified by id) that has the
* capability of cooling
*
* @param state The path variable that indicates the return should have or not have the
* capability
* @return An array of drone id with cooling capability.
*/
@GetMapping("/dronesWithCooling/{state}")
public List<String> getDronesWithCoolingCapability(@PathVariable boolean state) {
return droneInfoService.dronesWithCooling(state);
}
/**
* Handles GET requests to retrieve the drone detail identified by id
*
* @param id The id of the drone to be queried.
* @return 200 with {@link Drone}-style json if success, 404 if {@code id} not found, 400
* otherwise
*/
@GetMapping("/droneDetails/{id}")
public ResponseEntity<Drone> getDroneDetail(@PathVariable String id) {
try {
Drone drone = droneInfoService.droneDetail(id);
return ResponseEntity.ok(drone);
} catch (IllegalArgumentException ex) {
return ResponseEntity.notFound().build();
}
}
/**
* Handles GET requests to retrieve an array of drone ids that {@code capability.attrName =
* attrVal}
*
* @param attrName The name of the attribute to be queried
* @param attrVal The value of the attribute to be queried
* @return An array of drone id that matches the attribute name and value
*/
@GetMapping("/queryAsPath/{attrName}/{attrVal}")
public List<String> getIdByAttrMap(
@PathVariable String attrName, @PathVariable String attrVal) {
return droneAttrComparatorService.dronesWithAttribute(attrName, attrVal);
}
@PostMapping("/query")
public List<String> getIdByAttrMapPost(@RequestBody AttrQueryRequest[] attrComparators) {
return droneAttrComparatorService.dronesSatisfyingAttributes(attrComparators);
}
@PostMapping("/queryAvailableDrones")
public List<String> queryAvailableDrones(@RequestBody MedDispatchRecRequest[] records) {
return droneInfoService.dronesMatchesRequirements(records);
}
@PostMapping("/calcDeliveryPath")
public DeliveryPathResponse calculateDeliveryPath(@RequestBody MedDispatchRecRequest[] record) {
return pathFinderService.calculateDeliveryPath(record);
}
@PostMapping("/calcDeliveryPathAsGeoJson")
public String calculateDeliveryPathAsGeoJson(@RequestBody MedDispatchRecRequest[] record) {
return pathFinderService.calculateDeliveryPathAsGeoJson(record);
}
}

View file

@ -0,0 +1,25 @@
package io.github.js0ny.ilp_coursework.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1")
public class GeoJsonDataController {
private final DroneInfoService droneInfoService;
public GeoJsonDataController(DroneInfoService droneInfoService) {
this.droneInfoService = droneInfoService;
}
@GetMapping("/getAllRestrictedAreaByGeoJson")
public String getAllRestrictedAreaGeoJson() throws JsonProcessingException {
return droneInfoService.fetchRestrictedAreasInGeoJson().stream().reduce("", String::concat);
}
}

View file

@ -0,0 +1,12 @@
package io.github.js0ny.ilp_coursework.data.common;
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
/**
* Represents a range of altitude values (that is a fly-zone in {@link RestrictedArea}).
*
* @param lower The lower bound of the altitude range.
* @param upper The upper bound of the altitude range. If {@code upper = -1}, then the region is not
* a fly zone.
*/
public record AltitudeRange(double lower, double upper) {}

View file

@ -0,0 +1,64 @@
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 Angle snap(double rawAngle) {
double normalized = normalize(rawAngle);
double snapped = Math.round(normalized / STEP) * STEP;
return new Angle(normalize(snapped));
}
public Angle offset(int increments) {
double rotated = degrees + increments * STEP;
return new Angle(normalize(rotated));
}
private static double normalize(double angle) {
double normalized = angle % 360;
if (normalized < 0) {
normalized += 360;
}
return normalized;
}
public static double toRadians(double degrees) {
return Math.toRadians(degrees);
}
public double toRadians() {
return Math.toRadians(degrees);
}
}

View file

@ -0,0 +1,20 @@
package io.github.js0ny.ilp_coursework.data.common;
import java.time.DayOfWeek;
import java.time.LocalTime;
public record DroneAvailability(String id, TimeWindow[] availability) {
public boolean checkAvailability(DayOfWeek day, LocalTime time) {
for (var a : availability) {
if (a.dayOfWeek().equals(day)) {
if (!time.isBefore(a.from()) && !time.isAfter(a.until())) {
return true;
}
}
}
return false;
}
}

View file

@ -0,0 +1,10 @@
package io.github.js0ny.ilp_coursework.data.common;
public record DroneCapability(
boolean cooling,
boolean heating,
float capacity,
int maxMoves,
float costPerMove,
float costInitial,
float costFinal) {}

View file

@ -0,0 +1,35 @@
package io.github.js0ny.ilp_coursework.data.common;
/**
* Represents the data transfer object for a point or coordinate that defines by a longitude and
* latitude
*
* @param lng longitude of the coordinate/point
* @param lat latitude of the coordinate/point
*/
public record LngLat(double lng, double lat) {
private static final double EPSILON = 1e-9;
public LngLat {
if (lat < -90 || lat > 90) {
throw new IllegalArgumentException(
"Latitude must be between -90 and +90 degrees. Got: " + lat);
}
if (lng < -180 || lng > 180) {
throw new IllegalArgumentException(
"Longitude must be between -180 and +180 degrees. Got: " + lng);
}
}
public LngLat(LngLatAlt coord) {
this(coord.lng(), coord.lat());
}
public boolean isSamePoint(LngLat other) {
if (other == null) {
return false;
}
return (Math.abs(lng - other.lng()) < EPSILON && Math.abs(lat - other.lat()) < EPSILON);
}
}

View file

@ -0,0 +1,11 @@
package io.github.js0ny.ilp_coursework.data.common;
/**
* Represents the data transfer object for a point or coordinate that defines by a longitude and
* latitude
*
* @param lng longitude of the coordinate/point
* @param lat latitude of the coordinate/point
* @param alt altitude of the coordinate/point
*/
public record LngLatAlt(double lng, double lat, double alt) {}

View file

@ -0,0 +1,75 @@
package io.github.js0ny.ilp_coursework.data.common;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Represents the data transfer object for a region definition
*
* <p>This record encapsulates the data for calculating if a coordinate is inside the region
*
* <p>A built-in method {@code isClosedTo} is defined to check this DTO is valid or not in the mean
* of closing polygon
*
* @param name The human-readable name for the region
* @param vertices list of coordinates that forms a polygon as a region.
* <p>In order to define a valid region, the last element of the list should be the same as the
* first, or known as closed
* @see RegionCheckRequest
* @see io.github.js0ny.ilp_coursework.service.GpsCalculationService#checkIsInRegion(LngLat, Region)
*/
public record Region(String name, List<LngLat> vertices) {
/**
* Magic number 4: For a polygon, 3 edges is required.
*
* <p>In this dto, edges + 1 vertices is required.
*/
private static final int MINIMUM_VERTICES = 4;
/**
* Method to check if the region has a valid polygon by checking if the {@code vertices} forms a
* closed polygon
*
* @return {@code true} if the {@code vertices} are able to form a polygon and form a closed
* polygon
*/
public boolean isClosed() {
if (vertices == null || vertices.size() < MINIMUM_VERTICES) {
return false;
}
LngLat first = vertices.getFirst();
LngLat last = vertices.getLast();
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

@ -0,0 +1,6 @@
package io.github.js0ny.ilp_coursework.data.common;
import java.time.DayOfWeek;
import java.time.LocalTime;
public record TimeWindow(DayOfWeek dayOfWeek, LocalTime from, LocalTime until) {}

View file

@ -0,0 +1,15 @@
package io.github.js0ny.ilp_coursework.data.external;
import io.github.js0ny.ilp_coursework.data.common.DroneCapability;
/** Represents the data transfer object for a drone, gained from the endpoints */
public record Drone(String name, String id, DroneCapability capability) {
public int parseId() {
try {
return Integer.parseInt(id);
} catch (NumberFormatException e) {
return id.hashCode();
}
}
}

View file

@ -0,0 +1,19 @@
package io.github.js0ny.ilp_coursework.data.external;
import io.github.js0ny.ilp_coursework.data.common.AltitudeRange;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.LngLatAlt;
import io.github.js0ny.ilp_coursework.data.common.Region;
import java.util.ArrayList;
import java.util.List;
public record RestrictedArea(String name, int id, AltitudeRange limits, LngLatAlt[] vertices) {
public Region toRegion() {
List<LngLat> vertices2D = new ArrayList<>();
for (var vertex : vertices) {
vertices2D.add(new LngLat(vertex.lng(), vertex.lat()));
}
return new Region(name, vertices2D);
}
}

View file

@ -0,0 +1,5 @@
package io.github.js0ny.ilp_coursework.data.external;
import io.github.js0ny.ilp_coursework.data.common.LngLatAlt;
public record ServicePoint(String name, int id, LngLatAlt location) {}

View file

@ -0,0 +1,18 @@
package io.github.js0ny.ilp_coursework.data.external;
import io.github.js0ny.ilp_coursework.data.common.DroneAvailability;
import org.springframework.lang.Nullable;
public record ServicePointDrones(int servicePointId, DroneAvailability[] drones) {
@Nullable
public DroneAvailability locateDroneById(String droneId) {
for (var drone : drones) {
if (drone.id().equals(droneId)) {
return drone;
}
}
return null;
}
}

View file

@ -0,0 +1,6 @@
package io.github.js0ny.ilp_coursework.data.request;
// TODO: Convert operator to Enum
// import io.github.js0ny.ilp_coursework.util.AttrOperator;
public record AttrQueryRequest(String attribute, String operator, String value) {}

View file

@ -0,0 +1,14 @@
package io.github.js0ny.ilp_coursework.data.request;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
/**
* Represents the data transfer object for a distance operation request.
*
* <p>This record encapsulates the data for several endpoints that involves two {@code LngLatDto}
* and serves as the data contract for those API operation
*
* @param position1 Nested object of {@link LngLat}
* @param position2 Nested object of {@link LngLat}
*/
public record DistanceRequest(LngLat position1, LngLat position2) {}

View file

@ -0,0 +1,15 @@
package io.github.js0ny.ilp_coursework.data.request;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import java.time.LocalDate;
import java.time.LocalTime;
@JsonIgnoreProperties(ignoreUnknown = true)
public record MedDispatchRecRequest(
int id, LocalDate date, LocalTime time, MedRequirement requirements, LngLat delivery) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record MedRequirement(float capacity, boolean cooling, boolean heating, float maxCost) {}
}

View file

@ -0,0 +1,16 @@
package io.github.js0ny.ilp_coursework.data.request;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
/**
* Represents the data transfer object for a movement action request.
*
* <p>This record encapsulates the data for endpoint /api/v1/nextPosition and serves as the data
* contract for this API operation
*
* @param start The starting coordinate of the movement
* @param angle The angle to movement in degree. This corresponds to compass directions. For
* example: 0 for East, 90 for North
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getNextPosition(MovementRequest)
*/
public record MovementRequest(LngLat start, double angle) {}

View file

@ -0,0 +1,18 @@
package io.github.js0ny.ilp_coursework.data.request;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
/**
* Represents the data transfer object for a region check request.
*
* <p>This record encapsulates the data for endpoint /api/v1/isInRegion and serves as the data
* contract for this API operation
*
* <p>
*
* @param position The coordinate to be checked
* @param region The region for the check. This is a nested object represented by {@link Region}
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getIsInRegion(RegionCheckRequest)
*/
public record RegionCheckRequest(LngLat position, Region region) {}

View file

@ -0,0 +1,11 @@
package io.github.js0ny.ilp_coursework.data.response;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import java.util.List;
public record DeliveryPathResponse(float totalCost, int totalMoves, DronePath[] dronePaths) {
public record DronePath(int droneId, List<Delivery> deliveries) {
public record Delivery(int deliveryId, List<LngLat> flightPath) {}
}
}

View file

@ -0,0 +1,52 @@
package io.github.js0ny.ilp_coursework.exception;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;
import java.util.Map;
import java.util.Optional;
/** Class that handles exception or failed request. Map all error requests to 400. */
@RestControllerAdvice
public class GlobalExceptionHandler {
/// Use a logger to save logs instead of passing them to user
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final Map<String, String> badRequestMap =
Map.of("status", "400", "error", "Bad Request");
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleHttpMessageNotReadable(HttpMessageNotReadableException ex) {
log.warn("Malformed JSON received: {}", ex.getMessage());
return badRequestMap;
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleIllegalArgument(IllegalArgumentException ex) {
String errorMessage =
Optional.ofNullable(ex.getMessage()).orElse("Invalid argument provided.");
log.warn("Illegal argument in request: {}", errorMessage);
return badRequestMap;
}
@ExceptionHandler(NullPointerException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleNullPointerException(Exception ex) {
log.error("NullPointerException occurred. Return 400 by default.", ex);
return badRequestMap;
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleGeneralException(Exception ex) {
log.error("Fallback exception received: {}", ex.getMessage());
return badRequestMap;
}
}

View file

@ -0,0 +1,129 @@
package io.github.js0ny.ilp_coursework.service;
import static io.github.js0ny.ilp_coursework.util.AttrComparator.isValueMatched;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest;
import io.github.js0ny.ilp_coursework.util.AttrOperator;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class DroneAttrComparatorService {
private final String baseUrl;
private final String dronesEndpoint = "drones";
private final RestTemplate restTemplate = new RestTemplate();
/** Constructor, handles the base url here. */
public DroneAttrComparatorService() {
String baseUrl = System.getenv("ILP_ENDPOINT");
if (baseUrl == null || baseUrl.isBlank()) {
this.baseUrl = "https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
} else {
// Defensive: Add '/' to the end of the URL
if (!baseUrl.endsWith("/")) {
baseUrl += "/";
}
this.baseUrl = baseUrl;
}
}
/**
* Return an array of ids of drones with a given attribute name and value.
*
* <p>Associated service method with {@code /queryAsPath/{attrName}/{attrVal}}
*
* @param attrName the attribute name to filter on
* @param attrVal the attribute value to filter on
* @return array of drone ids matching the attribute name and value
* @see #dronesWithAttributeCompared(String, String, AttrOperator)
* @see io.github.js0ny.ilp_coursework.controller.DroneController#getIdByAttrMap
*/
public List<String> dronesWithAttribute(String attrName, String attrVal) {
// Call the helper with EQ operator
return dronesWithAttributeCompared(attrName, attrVal, AttrOperator.EQ);
}
/**
* Return an array of ids of drones which matches all given complex comparing rules
*
* @param attrComparators The filter rule with Name, Value and Operator
* @return array of drone ids that matches all rules
*/
public List<String> dronesSatisfyingAttributes(AttrQueryRequest[] attrComparators) {
Set<String> matchingDroneIds = null;
for (var comparator : attrComparators) {
String attribute = comparator.attribute();
String operator = comparator.operator();
String value = comparator.value();
AttrOperator op = AttrOperator.fromString(operator);
List<String> ids = dronesWithAttributeCompared(attribute, value, op);
if (matchingDroneIds == null) {
matchingDroneIds = new HashSet<>(ids);
} else {
matchingDroneIds.retainAll(ids);
}
}
if (matchingDroneIds == null) {
return new ArrayList<>();
}
return matchingDroneIds.stream().toList();
}
/**
* Helper that wraps the dynamic querying with different comparison operators
*
* <p>This method act as a concatenation of {@link
* io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode, String,
* AttrOperator)}
*
* @param attrName the attribute name to filter on
* @param attrVal the attribute value to filter on
* @param op the comparison operator
* @return array of drone ids matching the attribute name and value (filtered by {@code op})
* @see io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode, String,
* AttrOperator)
*/
private List<String> dronesWithAttributeCompared(
String attrName, String attrVal, AttrOperator op) {
URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
// This is required to make sure the response is valid
Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class);
if (drones == null) {
return new ArrayList<>();
}
// Use Jackson's ObjectMapper to convert DroneDto to JsonNode for dynamic
// querying
ObjectMapper mapper = new ObjectMapper();
return Arrays.stream(drones)
.filter(
drone -> {
JsonNode node = mapper.valueToTree(drone);
JsonNode attrNode = node.findValue(attrName);
if (attrNode != null) {
// Manually handle different types of JsonNode
return isValueMatched(attrNode, attrVal, op);
} else {
return false;
}
})
.map(Drone::id)
.collect(Collectors.toList());
}
}

View file

@ -0,0 +1,281 @@
package io.github.js0ny.ilp_coursework.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class DroneInfoService {
private final String baseUrl;
private final String dronesForServicePointsEndpoint = "drones-for-service-points";
public static final String servicePointsEndpoint = "service-points";
public static final String restrictedAreasEndpoint = "restricted-areas";
private final RestTemplate restTemplate;
/** Constructor, handles the base url here. */
public DroneInfoService() {
this(new RestTemplate());
}
public DroneInfoService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
String baseUrl = System.getenv("ILP_ENDPOINT");
if (baseUrl == null || baseUrl.isBlank()) {
this.baseUrl = "https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
} else {
// Defensive: Add '/' to the end of the URL
if (!baseUrl.endsWith("/")) {
baseUrl += "/";
}
this.baseUrl = baseUrl;
}
}
/**
* Return an array of ids of drones with/without cooling capability
*
* <p>Associated service method with {@code /dronesWithCooling/{state}}
*
* @param state determines the capability filtering
* @return if {@code state} is true, return ids of drones with cooling capability, else without
* cooling
* @see
* io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean)
*/
public List<String> dronesWithCooling(boolean state) {
// URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
// Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class);
List<Drone> drones = fetchAllDrones();
if (drones == null) {
return new ArrayList<>();
}
return drones.stream()
.filter(drone -> drone.capability().cooling() == state)
.map(Drone::id)
.collect(Collectors.toList());
}
/**
* Return a {@link Drone}-style json data structure with the given {@code id}
*
* <p>Associated service method with {@code /droneDetails/{id}}
*
* @param id The id of the drone
* @return drone json body of given id
* @throws NullPointerException when cannot fetch available drones from remote
* @throws IllegalArgumentException when drone with given {@code id} cannot be found this should
* lead to a 404
* @see io.github.js0ny.ilp_coursework.controller.DroneController#getDroneDetail(String)
*/
public Drone droneDetail(String id) {
List<Drone> drones = fetchAllDrones();
for (var drone : drones) {
if (drone.id().equals(id)) {
return drone;
}
}
// This will result in 404
throw new IllegalArgumentException("drone with that ID cannot be found");
}
/**
* Return an array of ids of drones that match all the requirements in the medical dispatch
* records
*
* <p>Associated service method with
*
* @param rec array of medical dispatch records
* @return List of drone ids that match all the requirements
* @see io.github.js0ny.ilp_coursework.controller.DroneController#queryAvailableDrones
*/
public List<String> dronesMatchesRequirements(MedDispatchRecRequest[] rec) {
List<Drone> drones = fetchAllDrones();
if (rec == null || rec.length == 0) {
return drones.stream()
.filter(Objects::nonNull)
.map(Drone::id)
.collect(Collectors.toList());
}
/*
* Traverse and filter drones, pass every record's requirement to helper
*/
return drones.stream()
.filter(d -> d != null && d.capability() != null)
.filter(
d ->
Arrays.stream(rec)
.filter(r -> r != null && r.requirements() != null)
.allMatch(r -> droneMatchesRequirement(d, r)))
.map(Drone::id)
.collect(Collectors.toList());
}
/**
* Helper to check if a drone meets the requirement of a medical dispatch.
*
* @param drone the drone to be checked
* @param record the medical dispatch record containing the requirement
* @return true if the drone meets the requirement, false otherwise
* @throws IllegalArgumentException when record requirements or drone capability is invalid
* (capacity and id cannot be null in {@code MedDispathRecDto})
*/
public boolean droneMatchesRequirement(Drone drone, MedDispatchRecRequest record) {
var requirements = record.requirements();
if (requirements == null) {
throw new IllegalArgumentException("requirements cannot be null");
}
var capability = drone.capability();
if (capability == null) {
throw new IllegalArgumentException("drone capability cannot be null");
}
float requiredCapacity = requirements.capacity();
if (requiredCapacity <= 0 || capability.capacity() < requiredCapacity) {
return false;
}
// Use boolean wrapper to allow null (not specified) values
boolean requiredCooling = requirements.cooling();
boolean requiredHeating = requirements.heating();
// Case 1: required is null: We don't care about it
// Case 2: required is false: We don't care about it (high capability adapts to
// low requirements)
// Case 3: capability is true: Then always matches
// See: https://piazza.com/class/me9vp64lfgf4sn/post/100
boolean matchesCooling = !requiredCooling || capability.cooling();
boolean matchesHeating = !requiredHeating || capability.heating();
// Conditions: All requirements matched + availability matched, use helper
// For minimal privilege, only pass drone id to check availability
return (matchesCooling && matchesHeating && checkAvailability(drone.id(), record)); // &&
// checkCost(drone, record) // checkCost is more expensive than
// checkAvailability
}
/**
* Helper to check if a drone is available at the required date and time
*
* @param droneId the id of the drone to be checked
* @param record the medical dispatch record containing the required date and time
* @return true if the drone is available, false otherwise
*/
private boolean checkAvailability(String droneId, MedDispatchRecRequest record) {
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints =
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
LocalDate requiredDate = record.date();
DayOfWeek requiredDay = requiredDate.getDayOfWeek();
LocalTime requiredTime = record.time();
assert servicePoints != null;
for (var servicePoint : servicePoints) {
var drone = servicePoint.locateDroneById(droneId); // Nullable
if (drone != null) {
return drone.checkAvailability(requiredDay, requiredTime);
}
}
return false;
}
private LngLat queryServicePointLocationByDroneId(String droneId) {
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints =
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
assert servicePoints != null;
for (var sp : servicePoints) {
var drone = sp.locateDroneById(droneId); // Nullable
if (drone != null) {
return queryServicePointLocation(sp.servicePointId());
}
}
return null;
}
@Nullable
private LngLat queryServicePointLocation(int id) {
URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
ServicePoint[] servicePoints =
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null;
for (var sp : servicePoints) {
if (sp.id() == id) {
// We dont consider altitude
return new LngLat(sp.location());
}
}
return null;
}
public List<Drone> fetchAllDrones() {
System.out.println("fetchAllDrones called");
String dronesEndpoint = "drones";
URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
System.out.println("Fetching from URL: " + droneUrl);
Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class);
return drones == null ? new ArrayList<>() : Arrays.asList(drones);
}
public List<RestrictedArea> fetchRestrictedAreas() {
URI restrictedUrl = URI.create(baseUrl).resolve(restrictedAreasEndpoint);
RestrictedArea[] restrictedAreas =
restTemplate.getForObject(restrictedUrl, RestrictedArea[].class);
assert restrictedAreas != null;
return Arrays.asList(restrictedAreas);
}
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(servicePointsEndpoint);
ServicePoint[] servicePoints =
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null;
return Arrays.asList(servicePoints);
}
public List<ServicePointDrones> fetchDronesForServicePoints() {
URI servicePointDronesUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePointDrones =
restTemplate.getForObject(servicePointDronesUrl, ServicePointDrones[].class);
assert servicePointDrones != null;
return Arrays.asList(servicePointDrones);
}
}

View file

@ -0,0 +1,189 @@
package io.github.js0ny.ilp_coursework.service;
import io.github.js0ny.ilp_coursework.data.common.Angle;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
import io.github.js0ny.ilp_coursework.data.request.DistanceRequest;
import io.github.js0ny.ilp_coursework.data.request.MovementRequest;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Class that handles calculations about Coordinates
*
* @see LngLat
* @see Region
*/
@Service
public class GpsCalculationService {
/**
* Given step size
*
* @see #nextPosition(LngLat, double)
*/
private static final double STEP = 0.00015;
/**
* Given threshold to judge if two points are close to each other
*
* @see #isCloseTo(LngLat, LngLat)
*/
private static final double CLOSE_THRESHOLD = 0.00015;
/**
* Calculate the Euclidean distance between {@code position1} and {@code position2}, which are
* coordinates defined as {@link LngLat}
*
* @param position1 The coordinate of the first position
* @param position2 The coordinate of the second position
* @return The Euclidean distance between {@code position1} and {@code position2}
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getDistance(DistanceRequest)
*/
public double calculateDistance(LngLat position1, LngLat position2) {
double lngDistance = position2.lng() - position1.lng();
double latDistance = position2.lat() - position1.lat();
// Euclidean: \sqrt{a^2 + b^2}
return Math.sqrt(lngDistance * lngDistance + latDistance * latDistance);
}
public double calculateSteps(LngLat position1, LngLat position2) {
double distance = calculateDistance(position1, position2);
return distance / STEP;
}
/**
* Check if {@code position1} and {@code position2} are close to each other, the threshold is <
* 0.00015
*
* <p>Note that = 0.00015 will be counted as not close to and will return {@code false}
*
* @param position1 The coordinate of the first position
* @param position2 The coordinate of the second position
* @return {@code true} if {@code position1} and {@code position2} are close to each other
* @see #CLOSE_THRESHOLD
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getIsCloseTo(DistanceRequest)
*/
public boolean isCloseTo(LngLat position1, LngLat position2) {
double distance = calculateDistance(position1, position2);
return distance < CLOSE_THRESHOLD;
}
/**
* Returns the next position moved from {@code start} in the direction with {@code 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 {@code start}
* @see #STEP
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getNextPosition(MovementRequest)
*/
public LngLat nextPosition(LngLat start, Angle angle) {
double rad = angle.toRadians();
double newLng = Math.cos(rad) * STEP + start.lng();
double newLat = Math.sin(rad) * STEP + start.lat();
return new LngLat(newLng, newLat);
}
/**
* Used to check if the given {@code position} is inside the {@code region}, on edge and vertex
* is considered as inside.
*
* @param position The coordinate of the position.
* @param region A {@link Region} that contains name and a list of {@code LngLatDto}
* @return {@code true} if {@code position} is inside the {@code region}.
* @throws IllegalArgumentException If {@code region} is not closed
* @see
* io.github.js0ny.ilp_coursework.controller.ApiController#getIsInRegion(RegionCheckRequest)
* @see Region#isClosed()
*/
public boolean checkIsInRegion(LngLat position, Region 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 {@code 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 {@code point} sits inside.
* @return If the {@code point} sits inside the {@code polygon} then return {@code true}
* @see #isPointOnEdge(LngLat, LngLat, LngLat)
* @see #checkIsInRegion(LngLat, Region)
*/
private boolean rayCasting(LngLat point, List<LngLat> polygon) {
int intersections = 0;
int n = polygon.size();
for (int i = 0; i < n; ++i) {
LngLat a = polygon.get(i);
LngLat 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()) {
LngLat 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());
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 {@code rayCasting} that used to simply calculation <br>
* Used to check if point {@code p} is on the edge formed by {@code a} and {@code 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 {@code true} if {@code p} is on {@code ab}
* @see #rayCasting(LngLat, List)
*/
private boolean isPointOnEdge(LngLat p, LngLat a, LngLat 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;
}
}

View file

@ -0,0 +1,531 @@
package io.github.js0ny.ilp_coursework.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import io.github.js0ny.ilp_coursework.controller.DroneController;
import io.github.js0ny.ilp_coursework.data.common.Angle;
import io.github.js0ny.ilp_coursework.data.common.DroneAvailability;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath.Delivery;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Class that handles calculations about deliverypath
*
* @see DroneInfoService
* @see DroneController
* @see DeliveryPathResponse
*/
@Service
public class PathFinderService {
/**
* Hard stop on how many pathfinding iterations we attempt for a single segment before bailing,
* useful for preventing infinite loops caused by precision quirks or unexpected map data.
*
* @see #computePath(LngLat, LngLat)
*/
private static final int MAX_SEGMENT_ITERATIONS = 8_000;
// Services
private final GpsCalculationService gpsCalculationService;
private final DroneInfoService droneInfoService;
private final ObjectMapper objectMapper;
private final List<Drone> drones;
private final Map<String, Drone> droneById;
private final Map<String, Integer> droneServicePointMap;
private final Map<Integer, LngLat> servicePointLocations;
private final List<Region> restrictedRegions;
/**
* Constructor for PathFinderService. The dependencies are injected by Spring and the
* constructor pre-computes reference maps used throughout the request lifecycle.
*
* @param gpsCalculationService Service handling geometric operations.
* @param droneInfoService Service that exposes drone metadata and capability information.
*/
public PathFinderService(
GpsCalculationService gpsCalculationService, DroneInfoService droneInfoService) {
this.gpsCalculationService = gpsCalculationService;
this.droneInfoService = droneInfoService;
this.objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
this.drones = droneInfoService.fetchAllDrones();
List<ServicePoint> servicePoints = droneInfoService.fetchServicePoints();
List<ServicePointDrones> servicePointAssignments =
droneInfoService.fetchDronesForServicePoints();
List<RestrictedArea> restrictedAreas = droneInfoService.fetchRestrictedAreas();
this.droneById = this.drones.stream().collect(Collectors.toMap(Drone::id, drone -> drone));
this.droneServicePointMap = new HashMap<>();
for (ServicePointDrones assignment : servicePointAssignments) {
if (assignment == null || assignment.drones() == null) {
continue;
}
for (DroneAvailability availability : assignment.drones()) {
if (availability == null || availability.id() == null) {
continue;
}
droneServicePointMap.put(availability.id(), assignment.servicePointId());
}
}
this.servicePointLocations =
servicePoints.stream()
.collect(
Collectors.toMap(
ServicePoint::id, sp -> new LngLat(sp.location())));
this.restrictedRegions = restrictedAreas.stream().map(RestrictedArea::toRegion).toList();
}
/**
* Produce a delivery plan for the provided dispatch records. Deliveries are grouped per
* compatible drone and per trip to satisfy each drone move limit.
*
* @param records Dispatch records to be fulfilled.
* @return Aggregated path response with cost and move totals.
* @see #calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[])
*/
public DeliveryPathResponse calculateDeliveryPath(MedDispatchRecRequest[] records) {
if (records == null || records.length == 0) {
return new DeliveryPathResponse(0f, 0, new DronePath[0]);
}
for (var r : records) {
if (isRestricted(r.delivery())) {
throw new IllegalStateException(
"Delivery "
+ r.id()
+ " is located within a restricted area and cannot be fulfilled");
}
}
Map<String, List<MedDispatchRecRequest>> assigned = assignDeliveries(records);
List<DronePath> paths = new ArrayList<>();
float totalCost = 0f;
int totalMoves = 0;
for (Map.Entry<String, List<MedDispatchRecRequest>> entry : assigned.entrySet()) {
String droneId = entry.getKey();
Drone drone = droneById.get(droneId);
if (drone == null) {
continue;
}
Integer spId = droneServicePointMap.get(droneId);
if (spId == null) {
continue;
}
LngLat servicePointLocation = servicePointLocations.get(spId);
if (servicePointLocation == null) {
continue;
}
List<MedDispatchRecRequest> sortedDeliveries =
entry.getValue().stream()
.sorted(
Comparator.comparingDouble(
rec ->
gpsCalculationService.calculateDistance(
servicePointLocation, rec.delivery())))
.toList();
List<List<MedDispatchRecRequest>> trips =
splitTrips(sortedDeliveries, drone, servicePointLocation);
for (List<MedDispatchRecRequest> trip : trips) {
TripResult result = buildTrip(drone, servicePointLocation, trip);
if (result != null) {
totalCost += result.cost();
totalMoves += result.moves();
paths.add(result.path());
}
}
}
return new DeliveryPathResponse(totalCost, totalMoves, paths.toArray(new DronePath[0]));
}
/**
* Convenience wrapper around {@link #calculateDeliveryPath} that serializes the result into a
* GeoJSON FeatureCollection suitable for mapping visualization.
*
* @param records Dispatch records to be fulfilled.
* @return GeoJSON payload representing every delivery flight path.
* @throws IllegalStateException When the payload cannot be serialized.
*/
public String calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[] records) {
DeliveryPathResponse response = calculateDeliveryPath(records);
Map<String, Object> featureCollection = new LinkedHashMap<>();
featureCollection.put("type", "FeatureCollection");
List<Map<String, Object>> features = new ArrayList<>();
if (response != null && response.dronePaths() != null) {
for (DronePath dronePath : response.dronePaths()) {
if (dronePath == null || dronePath.deliveries() == null) {
continue;
}
for (Delivery delivery : dronePath.deliveries()) {
Map<String, Object> feature = new LinkedHashMap<>();
feature.put("type", "Feature");
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("droneId", dronePath.droneId());
properties.put("deliveryId", delivery.deliveryId());
feature.put("properties", properties);
Map<String, Object> geometry = new LinkedHashMap<>();
geometry.put("type", "LineString");
List<List<Double>> coordinates = new ArrayList<>();
if (delivery.flightPath() != null) {
for (LngLat point : delivery.flightPath()) {
coordinates.add(List.of(point.lng(), point.lat()));
}
}
geometry.put("coordinates", coordinates);
feature.put("geometry", geometry);
features.add(feature);
}
}
}
featureCollection.put("features", features);
try {
return objectMapper.writeValueAsString(featureCollection);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to generate GeoJSON payload", e);
}
}
/**
* Group dispatch records by their assigned drone, ensuring every record is routed through
* {@link #findBestDrone(MedDispatchRecRequest)} exactly once and discarding invalid entries.
*
* @param records Dispatch records to be grouped.
* @return Map keyed by drone ID with the deliveries it should service.
*/
private Map<String, List<MedDispatchRecRequest>> assignDeliveries(
MedDispatchRecRequest[] records) {
Map<String, List<MedDispatchRecRequest>> assignments = new LinkedHashMap<>();
for (MedDispatchRecRequest record : records) {
if (record == null || record.delivery() == null) {
continue;
}
String droneId = findBestDrone(record);
assignments.computeIfAbsent(droneId, id -> new ArrayList<>()).add(record);
}
return assignments;
}
/**
* Choose the best drone for the provided record. Currently that equates to picking the closest
* compatible drone to the delivery location.
*
* @param record Dispatch record that needs fulfillment.
* @return Identifier of the drone that should fly the mission.
* @throws IllegalStateException If no available drone can serve the request.
*/
private String findBestDrone(MedDispatchRecRequest record) {
double bestScore = Double.MAX_VALUE;
String bestDrone = null;
for (Drone drone : drones) {
if (!droneInfoService.droneMatchesRequirement(drone, record)) {
continue;
}
String droneId = drone.id();
Integer servicePointId = droneServicePointMap.get(droneId);
if (servicePointId == null) {
continue;
}
LngLat servicePointLocation = servicePointLocations.get(servicePointId);
if (servicePointLocation == null) {
continue;
}
double distance =
gpsCalculationService.calculateDistance(
servicePointLocation, record.delivery());
if (distance < bestScore) {
bestScore = distance;
bestDrone = droneId;
}
}
if (bestDrone == null) {
throw new IllegalStateException("No available drone for delivery " + record.id());
}
return bestDrone;
}
/**
* Break a sequence of deliveries into several trips that each respect the drone move limit. The
* deliveries should already be ordered by proximity for sensible grouping.
*
* @param deliveries Deliveries assigned to a drone.
* @param drone Drone that will service the deliveries.
* @param servicePoint Starting and ending point of every trip.
* @return Partitioned trips with at least one delivery each.
* @throws IllegalStateException If a single delivery exceeds the drone's move limit.
*/
private List<List<MedDispatchRecRequest>> splitTrips(
List<MedDispatchRecRequest> deliveries, Drone drone, LngLat servicePoint) {
List<List<MedDispatchRecRequest>> trips = new ArrayList<>();
List<MedDispatchRecRequest> currentTrip = new ArrayList<>();
for (MedDispatchRecRequest delivery : deliveries) {
currentTrip.add(delivery);
int requiredMoves = estimateTripMoves(servicePoint, currentTrip);
if (requiredMoves > drone.capability().maxMoves()) {
currentTrip.remove(currentTrip.size() - 1);
if (currentTrip.isEmpty()) {
throw new IllegalStateException(
"Delivery "
+ delivery.id()
+ " exceeds drone "
+ drone.id()
+ " move limit");
}
trips.add(new ArrayList<>(currentTrip));
currentTrip.clear();
currentTrip.add(delivery);
}
}
if (!currentTrip.isEmpty()) {
int requiredMoves = estimateTripMoves(servicePoint, currentTrip);
if (requiredMoves > drone.capability().maxMoves()) {
throw new IllegalStateException(
"Delivery plan exceeds move limit for drone " + drone.id());
}
trips.add(new ArrayList<>(currentTrip));
}
return trips;
}
/**
* Build a single trip for the provided drone, including the entire flight path to every
* delivery and back home. The resulting structure contains the {@link DronePath} representation
* as well as cost and moves consumed.
*
* @param drone Drone executing the trip.
* @param servicePoint Starting/ending location of the trip.
* @param deliveries Deliveries to include in the trip in execution order.
* @return Trip information or {@code null} if no deliveries are provided.
* @see DeliveryPathResponse.DronePath
*/
private TripResult buildTrip(
Drone drone, LngLat servicePoint, List<MedDispatchRecRequest> deliveries) {
if (deliveries == null || deliveries.isEmpty()) {
return null;
}
List<Delivery> flightPlans = new ArrayList<>();
LngLat current = servicePoint;
int moves = 0;
for (int i = 0; i < deliveries.size(); i++) {
MedDispatchRecRequest delivery = deliveries.get(i);
PathSegment toDelivery = computePath(current, delivery.delivery());
List<LngLat> flightPath = new ArrayList<>(toDelivery.positions());
if (!flightPath.isEmpty()) {
LngLat last = flightPath.get(flightPath.size() - 1);
if (!last.isSamePoint(delivery.delivery())) {
flightPath.add(delivery.delivery());
}
} else {
flightPath.add(current);
flightPath.add(delivery.delivery());
}
flightPath.add(delivery.delivery());
moves += toDelivery.moves();
if (i == deliveries.size() - 1) {
PathSegment backHome = computePath(delivery.delivery(), servicePoint);
backHome.appendSkippingStart(flightPath);
moves += backHome.moves();
current = servicePoint;
} else {
current = delivery.delivery();
}
flightPlans.add(new Delivery(delivery.id(), flightPath));
}
float cost =
drone.capability().costInitial()
+ drone.capability().costFinal()
+ (float) (drone.capability().costPerMove() * moves);
DronePath path = new DronePath(drone.parseId(), flightPlans);
return new TripResult(path, moves, cost);
}
/**
* Estimate the number of moves a prospective trip would need by replaying the path calculation
* without mutating any persistent state.
*
* @param servicePoint Trip origin.
* @param deliveries Deliveries that would compose the trip.
* @return Total moves required to fly the proposed itinerary.
*/
private int estimateTripMoves(LngLat servicePoint, List<MedDispatchRecRequest> deliveries) {
if (deliveries.isEmpty()) {
return 0;
}
int moves = 0;
LngLat current = servicePoint;
for (MedDispatchRecRequest delivery : deliveries) {
PathSegment segment = computePath(current, delivery.delivery());
moves += segment.moves();
current = delivery.delivery();
}
moves += computePath(current, servicePoint).moves();
return moves;
}
/**
* Build a path between {@code start} and {@code target} by repeatedly moving in snapped
* increments while avoiding restricted zones.
*
* @param start Start coordinate.
* @param target Destination coordinate.
* @return Sequence of visited coordinates and move count.
* @see #nextPosition(LngLat, LngLat)
*/
private PathSegment computePath(LngLat start, LngLat target) {
List<LngLat> positions = new ArrayList<>();
if (start == null || target == null) {
return new PathSegment(positions, 0);
}
positions.add(start);
LngLat current = start;
int iterations = 0;
while (!gpsCalculationService.isCloseTo(current, target)
&& iterations < MAX_SEGMENT_ITERATIONS) {
LngLat next = nextPosition(current, target);
if (next.isSamePoint(current)) {
break;
}
positions.add(next);
current = next;
iterations++;
}
if (!positions.get(positions.size() - 1).isSamePoint(target)) {
positions.add(target);
}
return new PathSegment(positions, Math.max(0, positions.size() - 1));
}
/**
* Determine the next position on the path from {@code current} toward {@code target},
* preferring the snapped angle closest to the desired heading that does not infiltrate a
* restricted region.
*
* @param current Current coordinate.
* @param target Destination coordinate.
* @return Next admissible coordinate or the original point if none can be found.
*/
private LngLat nextPosition(LngLat current, LngLat target) {
double desiredAngle =
Math.toDegrees(
Math.atan2(target.lat() - current.lat(), target.lng() - current.lng()));
List<Angle> candidateAngles = buildAngleCandidates(desiredAngle);
for (Angle angle : candidateAngles) {
LngLat next = gpsCalculationService.nextPosition(current, angle);
if (!isRestricted(next)) {
return next;
}
}
return current;
}
/**
* Build a sequence of candidate angles centered on the desired heading, expanding symmetrically
* clockwise and counter-clockwise to explore alternative headings if the primary path is
* blocked.
*
* @param desiredAngle Bearing in degrees between current and target positions.
* @return Ordered list of candidate snapped angles.
* @see Angle#snap(double)
*/
private List<Angle> buildAngleCandidates(double desiredAngle) {
List<Angle> angles = new LinkedList<>();
Angle snapped = Angle.snap(desiredAngle);
angles.add(snapped);
for (int offset = 1; offset <= 8; offset++) {
angles.add(snapped.offset(offset));
angles.add(snapped.offset(-offset));
}
return angles;
}
/**
* Check whether the provided coordinate falls inside any restricted region.
*
* @param position Coordinate to inspect.
* @return {@code true} if the position intersects a restricted area.
* @see #restrictedRegions
*/
private boolean isRestricted(LngLat position) {
for (Region region : restrictedRegions) {
if (gpsCalculationService.checkIsInRegion(position, region)) {
return true;
}
}
return false;
}
/**
* Representation of a computed path segment wrapping the visited positions and the number of
* moves taken to traverse them.
*
* @param positions Ordered coordinates that describe the path.
* @param moves Number of moves consumed by the path.
*/
private record PathSegment(List<LngLat> positions, int moves) {
/**
* Append the positions from this segment to {@code target}, skipping the first coordinate
* as it is already represented by the last coordinate in the consumer path.
*
* @param target Mutable list to append to.
*/
private void appendSkippingStart(List<LngLat> target) {
for (int i = 1; i < positions.size(); i++) {
target.add(positions.get(i));
}
}
}
/**
* Bundle containing the calculated {@link DronePath}, total moves and financial cost for a
* single trip.
*/
private record TripResult(DronePath path, int moves, float cost) {}
}

View file

@ -0,0 +1,63 @@
package io.github.js0ny.ilp_coursework.util;
import com.fasterxml.jackson.databind.JsonNode;
import java.math.BigDecimal;
/**
* Comparator for attribute values in {@code JsonNode}.
*
* <p>This is a helper for dynamic querying.
*/
public class AttrComparator {
/**
* Helper for dynamic querying, to compare the json value with given value in {@code String}.
*
* @param node The {@code JsonNode} to be compared
* @param attrVal The Value passed, in {@code String}
* @param op The comparison operator
* @return {@code true} if given values are equal, otherwise false.
*/
public static boolean isValueMatched(JsonNode node, String attrVal, AttrOperator op) {
if (node.isTextual()) {
return compareStrings(node.asText(), attrVal, op);
} else if (node.isNumber()) {
// return Double.compare(node.asDouble(), Double.parseDouble(attrVal)) == 0;
return compareNumbers(node.decimalValue(), new BigDecimal(attrVal), op);
} else if (node.isBoolean()) {
return compareBooleans(node.asBoolean(), Boolean.parseBoolean(attrVal), op);
} else {
return false;
}
}
private static boolean compareNumbers(BigDecimal nodeVal, BigDecimal attrVal, AttrOperator op) {
int comparison = nodeVal.compareTo(attrVal);
return switch (op) {
case EQ -> comparison == 0;
case GT -> comparison > 0;
case LT -> comparison < 0;
case NE -> comparison != 0;
};
}
private static boolean compareStrings(String nodeVal, String attrVal, AttrOperator op) {
return switch (op) {
case EQ -> nodeVal.equals(attrVal);
default -> !nodeVal.equals(attrVal);
// case NE -> !nodeVal.equals(attrVal);
// case GT -> !nodeVal.equals(attrVal);// > 0;
// case LT -> !nodeVal.equals(attrVal);// < 0;
};
}
private static boolean compareBooleans(boolean nodeVal, boolean attrVal, AttrOperator op) {
return switch (op) {
case EQ -> nodeVal == attrVal;
default -> nodeVal != attrVal;
// case NE -> nodeVal != attrVal;
// case GT -> !nodeVal && attrVal; // false < true
// case LT -> nodeVal && !attrVal; // true > false
};
}
}

View file

@ -0,0 +1,23 @@
package io.github.js0ny.ilp_coursework.util;
public enum AttrOperator {
EQ("="),
NE("!="),
GT(">"),
LT("<");
private final String symbol;
AttrOperator(String symbol) {
this.symbol = symbol;
}
public static AttrOperator fromString(String symbol) {
for (AttrOperator op : AttrOperator.values()) {
if (op.symbol.equals(symbol)) {
return op;
}
}
throw new IllegalArgumentException("Unknown operator: " + symbol);
}
}

View file

@ -0,0 +1 @@
spring.application.name=ilp-coursework

View file

@ -0,0 +1,36 @@
package io.github.js0ny.ilp_coursework;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
public class ActuatorHealthTest {
@Autowired private MockMvc mockMvc;
@Test
@DisplayName("GET /actuator/health -> 200 OK")
void getActuator_shouldReturn200AndON() throws Exception {
String endpoint = "/actuator/health";
String expected =
"""
{
"status": "UP"
}
""";
var mock = mockMvc.perform(get(endpoint));
mock.andDo(print());
mock.andExpect(status().isOk());
mock.andExpect(content().json(expected));
}
}

View file

@ -0,0 +1,11 @@
package io.github.js0ny.ilp_coursework;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class IlpCourseworkApplicationTests {
@Test
void contextLoads() {}
}

View file

@ -0,0 +1,306 @@
package io.github.js0ny.ilp_coursework.controller;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.Angle;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
import io.github.js0ny.ilp_coursework.data.request.DistanceRequest;
import io.github.js0ny.ilp_coursework.data.request.MovementRequest;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import io.github.js0ny.ilp_coursework.service.GpsCalculationService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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;
import java.util.List;
@WebMvcTest(ApiController.class)
public class ApiControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@MockitoBean private GpsCalculationService service;
@Nested
@DisplayName("GET /uid")
class GetUidTests {
@Test
@DisplayName("GET /uid -> 200 OK")
void getUid_shouldReturn200AndStudentIdFromService() throws Exception {
String endpoint = "/api/v1/uid";
String expected = "s2522255";
var mock = mockMvc.perform(get(endpoint));
mock.andExpect(status().isOk());
mock.andExpect(content().string(expected));
}
}
@Nested
@DisplayName("POST /distanceTo")
class GetDistanceTests {
@Test
@DisplayName("POST /distanceTo -> 200 OK")
void getDistance_shouldReturn200AndDistance_whenCorrectInput() throws Exception {
double expected = 5.0;
String endpoint = "/api/v1/distanceTo";
LngLat p1 = new LngLat(0, 4.0);
LngLat p2 = new LngLat(3.0, 0);
var req = new DistanceRequest(p1, p2);
when(service.calculateDistance(any(LngLat.class), any(LngLat.class)))
.thenReturn(expected);
var mock =
mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)));
mock.andExpect(status().isOk());
mock.andExpect(content().string(String.valueOf(expected)));
}
@Test
@DisplayName("POST /distanceTo -> 400 Bad Request: Missing Field")
void getDistance_shouldReturn400_whenMissingField() throws Exception {
String endpoint = "/api/v1/distanceTo";
String req =
"""
{
"position1": {
"lng": 3.0,
"lat": 4.0
}
}
""";
when(service.calculateDistance(any(LngLat.class), isNull()))
.thenThrow(new NullPointerException());
mockMvc.perform(post(endpoint).contentType(MediaType.APPLICATION_JSON).content(req))
.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
@DisplayName("POST /isCloseTo")
class IsCloseToTests {
@Test
@DisplayName("POST /isCloseTo -> 200 OK")
void getIsCloseTo_shouldReturn200AndBoolean_whenCorrectInput() throws Exception {
boolean expected = false;
String endpoint = "/api/v1/isCloseTo";
LngLat p1 = new LngLat(0, 4.0);
LngLat p2 = new LngLat(3.0, 0);
var req = new DistanceRequest(p1, p2);
when(service.isCloseTo(any(LngLat.class), any(LngLat.class))).thenReturn(expected);
var mock =
mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)));
mock.andExpect(status().isOk());
mock.andExpect(content().string(String.valueOf(expected)));
}
@Test
@DisplayName("POST /isCloseTo -> 400 Bad Request: Malformed JSON ")
void getIsCloseTo_shouldReturn400_whenJsonIsMalformed() throws Exception {
// json without a bracket
String malformedJson =
"""
{
"position1": { "lng": 0.0, "lat": 3.0 }
""";
mockMvc.perform(
post("/api/v1/isCloseTo")
.contentType(MediaType.APPLICATION_JSON)
.content(malformedJson))
.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
@DisplayName("POST /nextPosition")
class GetNextPositionTests {
String endpoint = "/api/v1/nextPosition";
@Test
@DisplayName("POST /nextPosition -> 200 OK")
void getNextPosition_shouldReturn200AndCoordinate_whenCorrectInput() throws Exception {
LngLat expected = new LngLat(0.00015, 0.0);
LngLat p = new LngLat(0, 0);
var req = new MovementRequest(p, 0);
when(service.nextPosition(any(LngLat.class), any(Angle.class))).thenReturn(expected);
var mock =
mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)));
mock.andExpect(status().isOk());
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("POST /nextPosition -> 400 Bad Request: Missing Field")
void getNextPosition_shouldReturn400_whenKeyNameError() throws Exception {
// "position" should be "start"
String malformedJson =
"""
{
"position": { "lng": 0.0, "lat": 3.0 },
"angle": 180
}
""";
when(service.nextPosition(isNull(), any(Angle.class)))
.thenThrow(new NullPointerException());
mockMvc.perform(
post("/api/v1/nextPosition")
.contentType(MediaType.APPLICATION_JSON)
.content(malformedJson))
.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
@DisplayName("POST /isInRegion")
class GetIsInRegionTests {
@Test
@DisplayName("POST /isInRegion -> 200 OK")
void getIsInRegion_shouldReturn200AndBoolean_whenCorrectInput() throws Exception {
boolean expected = false;
String endpoint = "/api/v1/isInRegion";
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)));
var req = new RegionCheckRequest(position, region);
when(service.checkIsInRegion(any(LngLat.class), any(Region.class)))
.thenReturn(expected);
var mock =
mockMvc.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)));
mock.andExpect(status().isOk());
mock.andExpect(content().string(String.valueOf(expected)));
}
@Test
@DisplayName(
"POST /isInRegion -> 400 Bad Request: Passing a list of empty vertices to"
+ " isInRegion")
void getIsInRegion_shouldReturn400_whenPassingIllegalArguments() throws Exception {
var position = new LngLat(1, 1);
var region = new Region("illegal", List.of());
var request = new RegionCheckRequest(position, region);
when(service.checkIsInRegion(any(LngLat.class), any(Region.class)))
.thenThrow(new IllegalArgumentException("Region is not closed."));
mockMvc.perform(
post("/api/v1/isInRegion")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName(
"POST /isInRegion -> 400 Bad Request: Passing a list of not-closing vertices to"
+ " isInRegion")
void getIsInRegion_shouldReturn400_whenPassingNotClosingVertices() throws Exception {
var position = new LngLat(1, 1);
var region =
new Region(
"illegal",
List.of(
new LngLat(1, 2),
new LngLat(3, 4),
new LngLat(5, 6),
new LngLat(7, 8),
new LngLat(9, 10)));
var request = new RegionCheckRequest(position, region);
when(service.checkIsInRegion(any(LngLat.class), any(Region.class)))
.thenThrow(new IllegalArgumentException("Region is not closed."));
mockMvc.perform(
post("/api/v1/isInRegion")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}
}

View file

@ -0,0 +1,412 @@
package io.github.js0ny.ilp_coursework.controller;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import io.github.js0ny.ilp_coursework.data.common.DroneCapability;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
import io.github.js0ny.ilp_coursework.service.DroneAttrComparatorService;
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import io.github.js0ny.ilp_coursework.service.PathFinderService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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 java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.Map;
@WebMvcTest(DroneController.class)
public class DroneControllerTest {
@Autowired private MockMvc mockMvc;
private ObjectMapper objectMapper;
@MockitoBean private DroneInfoService droneInfoService;
@MockitoBean private DroneAttrComparatorService droneAttrComparatorService;
@MockitoBean private PathFinderService pathFinderService;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
}
@Nested
@DisplayName("GET /dronesWithCooling/{state}")
class GetDronesWithCoolingTest {
final String API_ENDPOINT_BASE = "/api/v1/dronesWithCooling/";
@Test
@DisplayName("true -> 200 OK")
void getDronesWithCooling_shouldReturn200AndArrayOfString_whenStateIsTrue()
throws Exception {
String endpoint = API_ENDPOINT_BASE + "true";
List<String> expected = List.of("1", "5", "8", "9");
when(droneInfoService.dronesWithCooling(anyBoolean())).thenReturn(expected);
var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk());
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("false -> 200 OK")
void getDronesWithCooling_shouldReturn200AndArrayOfString_whenStateIsFalse()
throws Exception {
String endpoint = API_ENDPOINT_BASE + "false";
List<String> expected = List.of("2", "3", "4", "6", "7", "10");
when(droneInfoService.dronesWithCooling(anyBoolean())).thenReturn(expected);
var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk());
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("-> 400 Bad Request")
void getDronesWithCooling_shouldReturn400_whenStateIsInvalid() throws Exception {
String endpoint = API_ENDPOINT_BASE + "invalid";
mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest());
}
}
@Nested
@DisplayName("GET /droneDetails/{droneId}")
class DroneDetailsTest {
static final String API_ENDPOINT_BASE = "/api/v1/droneDetails/";
@Test
@DisplayName("-> 200 OK")
void getDroneDetails_shouldReturn200AndJson_whenCorrectInput() throws Exception {
Drone expected =
new Drone(
"Drone 1",
"1",
new DroneCapability(true, true, 4.0f, 2000, 0.01f, 4.3f, 6.5f));
String endpoint = API_ENDPOINT_BASE + "1";
when(droneInfoService.droneDetail(anyString())).thenReturn(expected);
var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk());
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("-> 404 Not Found")
void getDroneDetails_shouldReturn404_whenDroneNotFound() throws Exception {
String endpoint = API_ENDPOINT_BASE + "invalidDroneId";
when(droneInfoService.droneDetail(anyString()))
.thenThrow(new IllegalArgumentException());
mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
}
@Nested
@DisplayName("GET /queryAsPath/{attrName}/{attrVal}")
class GetQueryAsPathTests {
final String API_ENDPOINT_BASE = "/api/v1/queryAsPath/";
@Test
@DisplayName("capacity = 8 -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenCapacityIs8() throws Exception {
String attrName = "capacity";
String attrVal = "8";
List<String> expected = List.of("2", "4", "7", "9");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected);
mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("heating = true -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenHeatingIsTrue() throws Exception {
String attrName = "heating";
String attrVal = "true";
List<String> expected = List.of("1", "2", "4", "5", "6", "7", "9");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected);
mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("cooling = false -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenCoolingIsFalse() throws Exception {
String attrName = "cooling";
String attrVal = "false";
List<String> expected = List.of("2", "3", "4", "6", "7", "10");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected);
mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("maxMoves = 1000 -> 200 OK")
void getQueryAsPath_shouldReturn200AndArrayOfString_whenMaxMovesIs1000() throws Exception {
String attrName = "maxMoves";
String attrVal = "1000";
List<String> expected = List.of("2", "4", "7", "9");
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected);
mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("invalid = null -> 200 OK (empty list)")
void getQueryAsPath_shouldReturn200AndEmptyArrayOfString_whenInvalidAttribute()
throws Exception {
String attrName = "invalid";
String attrVal = "null";
List<String> expected = List.of();
when(droneAttrComparatorService.dronesWithAttribute(attrName, attrVal))
.thenReturn(expected);
mockMvc.perform(
get(API_ENDPOINT_BASE + attrName + "/" + attrVal)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
}
@Nested
@DisplayName("POST /query")
class PostQueryTests {
final String API_ENDPOINT = "/api/v1/query";
@Test
@DisplayName("three eqs -> 200 OK")
void postQuery_shouldReturn200AndArrayOfString_whenThreeEqualsConditions()
throws Exception {
AttrQueryRequest req1 = new AttrQueryRequest("capacity", "=", "20");
AttrQueryRequest req2 = new AttrQueryRequest("heating", "=", "false");
AttrQueryRequest req3 = new AttrQueryRequest("cooling", "=", "true");
AttrQueryRequest[] requestBody = {req1, req2, req3};
List<String> expected = List.of("8");
when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected);
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("GT LT -> 200 OK")
void postQuery_shouldReturn200AndArrayOfString_whenGtLtConditions() throws Exception {
AttrQueryRequest req1 = new AttrQueryRequest("capacity", ">", "8");
AttrQueryRequest req2 = new AttrQueryRequest("maxMoves", "<", "2000");
AttrQueryRequest[] requestBody = {req1, req2};
List<String> expected = List.of("5", "10");
when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected);
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("GT LT -contradict -> 200 OK (empty list)")
void postQuery_shouldReturn200AndEmptyArrayOfString_whenContradictoryConditions()
throws Exception {
AttrQueryRequest req1 = new AttrQueryRequest("capacity", ">", "8");
AttrQueryRequest req2 = new AttrQueryRequest("capacity", "<", "8");
AttrQueryRequest[] requestBody = {req1, req2};
List<String> expected = List.of();
when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected);
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("GT LT EQ -> 200 OK")
void postQuery_shouldReturn200AndArrayOfString_whenGtLtEqConditions() throws Exception {
AttrQueryRequest req1 = new AttrQueryRequest("capacity", ">", "8");
AttrQueryRequest req2 = new AttrQueryRequest("maxMoves", "<", "2000");
AttrQueryRequest req3 = new AttrQueryRequest("cooling", "=", "true");
AttrQueryRequest[] requestBody = {req1, req2, req3};
List<String> expected = List.of("5");
when(droneAttrComparatorService.dronesSatisfyingAttributes(
any(AttrQueryRequest[].class)))
.thenReturn(expected);
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
}
@Nested
@DisplayName("POST /queryAvailableDrones")
class PostQueryAvailableDronesTests {
final String API_ENDPOINT = "/api/v1/queryAvailableDrones";
@Test
@DisplayName("Example -> 200 OK")
void postQueryAvailableDrones_shouldReturn200AndArrayOfString_whenExampleRequest()
throws Exception {
var reqs = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
var delivery = new LngLat(-3.00, 55.121);
var record =
new MedDispatchRecRequest(
123,
LocalDate.parse("2025-12-22"),
LocalTime.parse("14:30"),
reqs,
delivery);
MedDispatchRecRequest[] requestBody = {record};
List<String> expected = List.of("1", "2", "6", "7", "9");
when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class)))
.thenReturn(expected);
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("Treat Null as False (Cooling) -> 200 OK")
void postQueryAvailableDrones_shouldReturn200AndArrayOfString_whenCoolingIsNull()
throws Exception {
var requestMap =
Map.of(
"id",
123,
"date",
"2025-12-22",
"time",
"14:30",
"requirements",
Map.of(
"capacity", 0.75,
"heating", true,
"maxCost", 13.5));
List<String> expected = List.of("1", "2", "6", "7", "9");
when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class)))
.thenReturn(expected);
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(
objectMapper.writeValueAsString(
new Object[] {requestMap})))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
@Test
@DisplayName("Complex -> 200 OK")
void postQueryAvailableDrones_shouldReturn200AndArrayOfString_whenComplexRequest()
throws Exception {
var reqs1 = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
var delivery1 = new LngLat(-3.00, 55.121);
var record1 =
new MedDispatchRecRequest(
123,
LocalDate.parse("2025-12-22"),
LocalTime.parse("14:30"),
reqs1,
delivery1);
var reqs2 = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
var delivery2 = new LngLat(-3.00, 55.121);
var record2 =
new MedDispatchRecRequest(
456,
LocalDate.parse("2025-12-25"),
LocalTime.parse("11:30"),
reqs2,
delivery2);
MedDispatchRecRequest[] requestBody = {record1, record2};
List<String> expected = List.of("2", "7", "9");
when(droneInfoService.dronesMatchesRequirements(any(MedDispatchRecRequest[].class)))
.thenReturn(expected);
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
}
}

View file

@ -0,0 +1,233 @@
package io.github.js0ny.ilp_coursework.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
import io.github.js0ny.ilp_coursework.data.common.DroneAvailability;
import io.github.js0ny.ilp_coursework.data.common.DroneCapability;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.TimeWindow;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
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 org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
@ExtendWith(MockitoExtension.class)
public class DroneInfoServiceTest {
@Mock private RestTemplate restTemplate;
private DroneInfoService droneInfoService;
private final String baseUrl = "http://localhost:8080/";
@BeforeEach
void setUp() {
droneInfoService = new DroneInfoService(restTemplate);
ReflectionTestUtils.setField(droneInfoService, "baseUrl", baseUrl);
}
private Drone[] getMockDrones() {
return new Drone[] {
new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)),
new Drone("Drone 2", "2", new DroneCapability(false, true, 20, 2000, 2, 2, 2)),
new Drone("Drone 3", "3", new DroneCapability(false, false, 30, 3000, 3, 3, 3))
};
}
@Nested
@DisplayName("dronesWithCooling(boolean) tests")
class DronesWithCoolingTests {
@Test
@DisplayName("Should return drones with cooling")
void dronesWithCooling_shouldReturnDronesWithCooling() {
// Arrange
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(getMockDrones());
// Act
List<String> result = droneInfoService.dronesWithCooling(true);
// Assert
assertThat(result).containsExactly("1");
}
@Test
@DisplayName("Should return drones without cooling")
void dronesWithCooling_shouldReturnDronesWithoutCooling() {
// Arrange
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(getMockDrones());
// Act
List<String> result = droneInfoService.dronesWithCooling(false);
// Assert
assertThat(result).containsExactly("2", "3");
}
@Test
@DisplayName("Should return empty list when API returns null")
void dronesWithCooling_shouldReturnEmptyList_whenApiReturnsNull() {
// Arrange
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(null);
// Act
List<String> result = droneInfoService.dronesWithCooling(true);
// Assert
assertThat(result).isEmpty();
}
}
@Nested
@DisplayName("droneDetail(String) tests")
class DroneDetailTests {
@Test
@DisplayName("Should return correct drone details")
void droneDetail_shouldReturnCorrectDrone() {
// Arrange
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(getMockDrones());
// Act
Drone result = droneInfoService.droneDetail("2");
// Assert
assertThat(result.id()).isEqualTo("2");
assertThat(result.name()).isEqualTo("Drone 2");
}
@Test
@DisplayName("Should throw exception for non-existent drone")
void droneDetail_shouldThrowException_forNonExistentDrone() {
// Arrange
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(getMockDrones());
// Act & Assert
assertThatThrownBy(() -> droneInfoService.droneDetail("4"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("drone with that ID cannot be found");
}
}
@Nested
@DisplayName("dronesMatchesRequirements(MedDispatchRecRequest[]) tests")
class DronesMatchesRequirementsTests {
private ServicePointDrones[] getMockServicePointDrones() {
TimeWindow[] timeWindows = {
new TimeWindow(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.of(17, 0))
};
DroneAvailability drone1Avail = new DroneAvailability("1", timeWindows);
ServicePointDrones spd =
new ServicePointDrones(1, new DroneAvailability[] {drone1Avail});
return new ServicePointDrones[] {spd};
}
@Test
@DisplayName("Should return drones matching a single requirement")
void dronesMatchesRequirements_shouldReturnMatchingDrones_forSingleRequirement() {
// Arrange
var drones =
new Drone[] {
new Drone(
"Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)),
new Drone(
"Drone 2", "2", new DroneCapability(false, true, 5, 2000, 2, 2, 2))
};
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(drones);
when(restTemplate.getForObject(
URI.create(baseUrl + "drones-for-service-points"),
ServicePointDrones[].class))
.thenReturn(getMockServicePointDrones());
var requirement = new MedDispatchRecRequest.MedRequirement(8, true, false, 100);
var record =
new MedDispatchRecRequest(
1,
LocalDate.now().with(DayOfWeek.MONDAY),
LocalTime.of(10, 0),
requirement,
new LngLat(0, 0));
// Act
List<String> result =
droneInfoService.dronesMatchesRequirements(
new MedDispatchRecRequest[] {record});
// Assert
assertThat(result).containsExactly("1");
}
@Test
@DisplayName("Should return empty list if no drones match")
void dronesMatchesRequirements_shouldReturnEmptyList_whenNoDronesMatch() {
// Arrange
var drones =
new Drone[] {
new Drone(
"Drone 1", "1", new DroneCapability(true, true, 5, 1000, 1, 1, 1)),
new Drone(
"Drone 2", "2", new DroneCapability(false, true, 5, 2000, 2, 2, 2))
};
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(drones);
// No need to mock drones-for-service-points as it won't be called
var requirement = new MedDispatchRecRequest.MedRequirement(10, true, false, 100);
var record =
new MedDispatchRecRequest(
1,
LocalDate.now().with(DayOfWeek.MONDAY),
LocalTime.of(10, 0),
requirement,
new LngLat(0, 0));
// Act
List<String> result =
droneInfoService.dronesMatchesRequirements(
new MedDispatchRecRequest[] {record});
// Assert
assertThat(result).isEmpty();
}
@Test
@DisplayName("Should return all drones if requirements are null or empty")
void dronesMatchesRequirements_shouldReturnAllDrones_forNullOrEmptyRequirements() {
// Arrange
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(getMockDrones());
// Act
List<String> resultNull = droneInfoService.dronesMatchesRequirements(null);
List<String> resultEmpty =
droneInfoService.dronesMatchesRequirements(new MedDispatchRecRequest[0]);
// Assert
assertThat(resultNull).containsExactly("1", "2", "3");
assertThat(resultEmpty).containsExactly("1", "2", "3");
}
}
}

View file

@ -0,0 +1,413 @@
package io.github.js0ny.ilp_coursework.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.within;
import io.github.js0ny.ilp_coursework.data.common.Angle;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
import 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;
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 LngLat(-3.192473, 55.946233);
var p2 = new LngLat(-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 LngLat(0, 3.0);
var p2 = new LngLat(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 LngLat(12.85, 68.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 LngLat(23.85, 83.2119);
var p2 = new LngLat(33.85, 83.2119);
double expected = 10.0;
double actual = service.calculateDistance(p1, p2);
assertThat(actual).isCloseTo(expected, within(PRECISION));
}
@Test
@DisplayName("Edge Case: Latitude-only movement")
void calculateDistance_shouldReturnCorrectDistance_forPointsWithSameLongitude() {
var p1 = new LngLat(123.85, 68.2119);
var p2 = new LngLat(123.85, 58.2119);
double expected = 10.0;
double actual = service.calculateDistance(p1, p2);
assertThat(actual).isCloseTo(expected, within(PRECISION));
}
@Test
@DisplayName("General Case: Calculate with negative Coordinates")
void calculateDistance_shouldReturnCorrectEuclideanDistance_forNegativeCoordinates() {
LngLat p1 = new LngLat(-1.0, -2.0);
LngLat p2 = new LngLat(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 LngLat(-3.192473, 55.946233);
var p2 = new LngLat(-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 LngLat(15.86, 28.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 LngLat(0.0, 0.0);
var p2 = new LngLat(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 LngLat(0.0, 0.0);
var p2 = new LngLat(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 LngLat(0.0, 0.0);
var p2 = new LngLat(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 LngLat(0.0, 0.0);
Angle angle = new Angle(0);
// For 0 degrees, cos(0)=1, sin(0)=0. Move happens entirely on lng axis.
var expected = new LngLat(STEP, 0.0);
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 LngLat(0.0, 0.0);
Angle angle = new Angle(90);
// For 90 degrees, cos(90)=0, sin(90)=1. Move happens entirely on lat axis.
var expected = new LngLat(0.0, STEP);
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 LngLat(0.0, 0.0);
Angle angle = new Angle(180);
// For 180 degrees, cos(180)=-1, sin(180)=0. Move happens entirely on negative
// lng axis.
var expected = new LngLat(-STEP, 0.0);
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 LngLat(0.0, 0.0);
Angle angle = new Angle(270);
// For 270 degrees, cos(270)=0, sin(270)=-1. Move happens entirely on negative
// lat axis.
var expected = new LngLat(0.0, -STEP);
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 LngLat(0.0, 0.0);
Angle angle = new Angle(45);
// Δlng = step * cos(45°), Δlat = step * sin(45°)
double expectedLng = STEP * Math.cos(angle.toRadians());
double expectedLat = STEP * Math.sin(angle.toRadians());
var expected = new LngLat(expectedLng, expectedLat);
var actual = service.nextPosition(start, angle);
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",
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.");
}
}
}
}

View file

@ -0,0 +1,147 @@
package io.github.js0ny.ilp_coursework.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.DroneAvailability;
import io.github.js0ny.ilp_coursework.data.common.DroneCapability;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.LngLatAlt;
import io.github.js0ny.ilp_coursework.data.common.TimeWindow;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest.MedRequirement;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Collections;
import java.util.List;
@ExtendWith(MockitoExtension.class)
class PathFinderServiceTest {
private static final String DRONE_ID = "test-drone";
private static final LngLat SERVICE_POINT_COORD = new LngLat(0.0, 0.0);
private final ObjectMapper mapper = new ObjectMapper();
@Mock private DroneInfoService droneInfoService;
private PathFinderService pathFinderService;
@BeforeEach
void setUpPathFinder() {
GpsCalculationService gpsCalculationService = new GpsCalculationService();
DroneCapability capability = new DroneCapability(false, true, 5.0f, 10, 0.1f, 0.5f, 0.5f);
Drone drone = new Drone("Test Drone", DRONE_ID, capability);
ServicePoint servicePoint =
new ServicePoint(
"Test Point",
1,
new LngLatAlt(SERVICE_POINT_COORD.lng(), SERVICE_POINT_COORD.lat(), 50.0));
DroneAvailability availability =
new DroneAvailability(
DRONE_ID,
new TimeWindow[] {
new TimeWindow(DayOfWeek.MONDAY, LocalTime.MIDNIGHT, LocalTime.MAX),
});
ServicePointDrones servicePointDrones =
new ServicePointDrones(servicePoint.id(), new DroneAvailability[] {availability});
when(droneInfoService.fetchAllDrones()).thenReturn(List.of(drone));
when(droneInfoService.fetchServicePoints()).thenReturn(List.of(servicePoint));
when(droneInfoService.fetchDronesForServicePoints())
.thenReturn(List.of(servicePointDrones));
when(droneInfoService.fetchRestrictedAreas())
.thenReturn(Collections.<RestrictedArea>emptyList());
when(droneInfoService.droneMatchesRequirement(any(), any())).thenReturn(true);
pathFinderService = new PathFinderService(gpsCalculationService, droneInfoService);
}
@Test
void calculateDeliveryPath_shouldStayWithinSingleTripBudget() {
MedDispatchRecRequest request = createSampleRequest();
DeliveryPathResponse response =
pathFinderService.calculateDeliveryPath(new MedDispatchRecRequest[] {request});
assertThat(response.totalMoves()).isGreaterThan(0);
assertThat(response.totalMoves()).isLessThanOrEqualTo(10);
assertThat(response.dronePaths()).hasSize(1);
DeliveryPathResponse.DronePath dronePath = response.dronePaths()[0];
assertThat(dronePath.droneId()).isNotZero();
assertThat(dronePath.deliveries()).hasSize(1);
var recordedPath = dronePath.deliveries().get(0).flightPath();
assertThat(recordedPath.get(0)).isEqualTo(SERVICE_POINT_COORD);
assertThat(samePoint(recordedPath.get(recordedPath.size() - 1), SERVICE_POINT_COORD))
.isTrue();
assertThat(hasHoverAt(recordedPath, request.delivery())).isTrue();
}
@Test
void calculateDeliveryPathAsGeoJson_shouldReturnFeatureCollection() throws IOException {
MedDispatchRecRequest request = createSampleRequest();
String geoJson =
pathFinderService.calculateDeliveryPathAsGeoJson(
new MedDispatchRecRequest[] {request});
JsonNode root = mapper.readTree(geoJson);
assertThat(root.get("type").asText()).isEqualTo("FeatureCollection");
JsonNode features = root.get("features");
assertThat(features.size()).isEqualTo(1);
JsonNode feature = features.get(0);
assertThat(feature.get("type").asText()).isEqualTo("Feature");
JsonNode props = feature.get("properties");
assertThat(props.get("droneId").asInt()).isNotZero();
assertThat(props.get("deliveryId").asInt()).isEqualTo(request.id());
JsonNode geometry = feature.get("geometry");
assertThat(geometry.get("type").asText()).isEqualTo("LineString");
JsonNode coordinates = geometry.get("coordinates");
assertThat(coordinates.size()).isGreaterThanOrEqualTo(2);
double startLng = coordinates.get(0).get(0).asDouble();
double startLat = coordinates.get(0).get(1).asDouble();
assertThat(samePoint(new LngLat(startLng, startLat), SERVICE_POINT_COORD)).isTrue();
}
private MedDispatchRecRequest createSampleRequest() {
return new MedDispatchRecRequest(
101,
LocalDate.of(2025, 1, 6),
LocalTime.of(12, 0),
new MedRequirement(0.5f, false, true, 50.0f),
new LngLat(SERVICE_POINT_COORD.lng() + 0.0003, SERVICE_POINT_COORD.lat()));
}
private boolean hasHoverAt(List<LngLat> path, LngLat target) {
for (int i = 0; i < path.size() - 1; i++) {
if (samePoint(path.get(i), target) && samePoint(path.get(i + 1), target)) {
return true;
}
}
return false;
}
private boolean samePoint(LngLat a, LngLat b) {
double threshold = 1e-9;
return (Math.abs(a.lng() - b.lng()) < threshold && Math.abs(a.lat() - b.lat()) < threshold);
}
}