chore: rebuild directory structure
This commit is contained in:
parent
d3ce236672
commit
dbf71443c7
91 changed files with 6683 additions and 1 deletions
9
ilp-rest-service/Dockerfile
Normal file
9
ilp-rest-service/Dockerfile
Normal 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
57
ilp-rest-service/Makefile
Normal 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}
|
||||
31
ilp-rest-service/build.gradle
Normal file
31
ilp-rest-service/build.gradle
Normal 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()
|
||||
}
|
||||
8
ilp-rest-service/compose.yaml
Normal file
8
ilp-rest-service/compose.yaml
Normal 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
27
ilp-rest-service/flake.lock
generated
Normal 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
|
||||
}
|
||||
43
ilp-rest-service/flake.nix
Normal file
43
ilp-rest-service/flake.nix
Normal 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)"
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
BIN
ilp-rest-service/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
ilp-rest-service/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
ilp-rest-service/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
ilp-rest-service/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
ilp-rest-service/gradlew
vendored
Executable 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
94
ilp-rest-service/gradlew.bat
vendored
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
8
ilp-rest-service/ilp-cw-api/CW1 Feedback API/folder.bru
Normal file
8
ilp-rest-service/ilp-cw-api/CW1 Feedback API/folder.bru
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
meta {
|
||||
name: CW1 Feedback API
|
||||
seq: 6
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
meta {
|
||||
name: [GET] droneDetails
|
||||
seq: 2
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
meta {
|
||||
name: [GET] dronesWithCooling
|
||||
seq: 1
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
8
ilp-rest-service/ilp-cw-api/[GET] queryAsPath/folder.bru
Normal file
8
ilp-rest-service/ilp-cw-api/[GET] queryAsPath/folder.bru
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
meta {
|
||||
name: [GET] queryAsPath
|
||||
seq: 3
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
meta {
|
||||
name: [POST] calcDeliveryPath(AsGeoJson)
|
||||
seq: 7
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
53
ilp-rest-service/ilp-cw-api/[POST] query/GT LT EQ.bru
Normal file
53
ilp-rest-service/ilp-cw-api/[POST] query/GT LT EQ.bru
Normal 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
|
||||
}
|
||||
48
ilp-rest-service/ilp-cw-api/[POST] query/GT LT.bru
Normal file
48
ilp-rest-service/ilp-cw-api/[POST] query/GT LT.bru
Normal 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
|
||||
}
|
||||
8
ilp-rest-service/ilp-cw-api/[POST] query/folder.bru
Normal file
8
ilp-rest-service/ilp-cw-api/[POST] query/folder.bru
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
meta {
|
||||
name: [POST] query
|
||||
seq: 4
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
53
ilp-rest-service/ilp-cw-api/[POST] query/three eqs.bru
Normal file
53
ilp-rest-service/ilp-cw-api/[POST] query/three eqs.bru
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
meta {
|
||||
name: [POST] queryAvailableDrones
|
||||
seq: 5
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
9
ilp-rest-service/ilp-cw-api/bruno.json
Normal file
9
ilp-rest-service/ilp-cw-api/bruno.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"version": "1",
|
||||
"name": "ILP CW API Collection",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
4
ilp-rest-service/ilp-cw-api/collection.bru
Normal file
4
ilp-rest-service/ilp-cw-api/collection.bru
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
vars:pre-request {
|
||||
BASE_URL: http://localhost:8080
|
||||
API_BASE: {{BASE_URL}}/api/v1
|
||||
}
|
||||
6682
ilp-rest-service/ilp-cw-api/results.json
Normal file
6682
ilp-rest-service/ilp-cw-api/results.json
Normal file
File diff suppressed because it is too large
Load diff
1
ilp-rest-service/settings.gradle
Normal file
1
ilp-rest-service/settings.gradle
Normal file
|
|
@ -0,0 +1 @@
|
|||
rootProject.name = 'ilp-coursework'
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
15
ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/data/external/Drone.java
vendored
Normal file
15
ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/data/external/Drone.java
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
19
ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/data/external/RestrictedArea.java
vendored
Normal file
19
ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/data/external/RestrictedArea.java
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
spring.application.name=ilp-coursework
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue