diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5275cec --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Created by .ignore support plugin (hsz.mobi) +.gradle/ +/.idea/* +!/.idea/codeStyles +!/.idea/watcherTasks.xml +!/.idea/runConfigurations/ +!/.idea/scopes/Code.xml +build/ +out/ \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..cba1d10 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..ad1ab4e --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.java-version b/.java-version new file mode 100644 index 0000000..3b5b5d8 --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +11.0 \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..aabdc03 --- /dev/null +++ b/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' version "$KOTLIN_VERSION" +} + +java.sourceCompatibility = JavaVersion.VERSION_11 +java.targetCompatibility = JavaVersion.VERSION_11 + +group "to.refactoring.javatokotlin.travelator" +version "1.0-SNAPSHOT" + +repositories { + mavenCentral() + jcenter() + mavenLocal() +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + + implementation "com.fasterxml.jackson.core:jackson-databind:2.10.0" + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.10.0" + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.10.0" + + testImplementation "org.junit.jupiter:junit-jupiter-api:5.4.2" + testImplementation "org.junit.jupiter:junit-jupiter-params:5.4.2" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.5.2" + testRuntimeOnly "org.junit.platform:junit-platform-launcher:1.4.2" +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) { + kotlinOptions { + jvmTarget = "11" + javaParameters = true + freeCompilerArgs = ["-Xjvm-default=all"] + } +} + +println("building with Kotlin $KOTLIN_VERSION") + +test { + useJUnitPlatform { + includeEngines "junit-jupiter" + } + testLogging { + events "skipped", "failed" + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2802402 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.parallel=true +org.gradle.caching=false + +KOTLIN_VERSION=1.5.0 +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..5c2d1cf Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9d6d2f8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Mar 15 11:22:46 GMT 2020 +distributionUrl=https\://services.gradle.org/distributions/gradle-6.2.2-all.zip +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..77564dd --- /dev/null +++ b/gradlew @@ -0,0 +1,188 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or 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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# 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"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# 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 + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# 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" + which java >/dev/null 2>&1 || 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 + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" \ No newline at end of file diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..15e3d84 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,100 @@ +@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 + +@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=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@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%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="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! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..70144f0 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'refactoring-to-kotlin-code' \ No newline at end of file diff --git a/src/main/java/travelator/Id.java b/src/main/java/travelator/Id.java new file mode 100644 index 0000000..b7ee536 --- /dev/null +++ b/src/main/java/travelator/Id.java @@ -0,0 +1,42 @@ +package travelator; + +import java.util.Objects; +import java.util.UUID; + +public class Id { + private final String raw; + + private Id(String raw) { + this.raw = raw; + } + + public static Id of(String raw) { + return new Id(raw); + } + + public static String raw(Id id) { + return id.raw; + } + + public static Id mint() { + return Id.of(UUID.randomUUID().toString()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Id id = (Id) o; + return raw.equals(id.raw); + } + + @Override + public int hashCode() { + return Objects.hash(raw); + } + + @Override + public String toString() { + return raw; + } +} \ No newline at end of file diff --git a/src/main/java/travelator/Location.java b/src/main/java/travelator/Location.java new file mode 100644 index 0000000..88b08a8 --- /dev/null +++ b/src/main/java/travelator/Location.java @@ -0,0 +1,47 @@ +package travelator; + +import java.util.Objects; + +public class Location { + private final Id id; + private final String localName; + private final String userReadableName; + + public Location(Id id, String localName, String userReadableName) { + this.id = id; + this.localName = localName; + this.userReadableName = userReadableName; + } + + public Id getId() { + return id; + } + + public String getLocalName() { + return localName; + } + + public String getUserReadableName() { + return userReadableName; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Location location = (Location) o; + return id.equals(location.id) && + localName.equals(location.localName) && + userReadableName.equals(location.userReadableName); + } + + @Override + public int hashCode() { + return Objects.hash(id, localName, userReadableName); + } + + @Override + public String toString() { + return "Location(" + id + ": " + userReadableName + ")"; + } +} \ No newline at end of file diff --git a/src/main/java/travelator/itinerary/CostSummary.java b/src/main/java/travelator/itinerary/CostSummary.java new file mode 100644 index 0000000..30625c3 --- /dev/null +++ b/src/main/java/travelator/itinerary/CostSummary.java @@ -0,0 +1,30 @@ +package travelator.itinerary; + +import travelator.money.CurrencyConversion; +import travelator.money.Money; + +import java.util.ArrayList; +import java.util.Currency; +import java.util.List; + +public class CostSummary { + private final List lines = new ArrayList<>(); + private Money total; + + public CostSummary(Currency userCurrency) { + this.total = Money.of(0, userCurrency); + } + + public void addLine(CurrencyConversion line) { + lines.add(line); + total = total.add(line.getToMoney()); + } + + public List getLines() { + return List.copyOf(lines); + } + + public Money getTotal() { + return total; + } +} \ No newline at end of file diff --git a/src/main/java/travelator/itinerary/CostSummaryCalculator.java b/src/main/java/travelator/itinerary/CostSummaryCalculator.java new file mode 100644 index 0000000..e63e97c --- /dev/null +++ b/src/main/java/travelator/itinerary/CostSummaryCalculator.java @@ -0,0 +1,45 @@ +package travelator.itinerary; + +import travelator.money.ExchangeRates; +import travelator.money.Money; + +import java.util.ArrayList; +import java.util.Currency; +import java.util.HashMap; +import java.util.Map; + +import static java.util.Comparator.comparing; + +public class CostSummaryCalculator { + private final Currency userCurrency; + private final ExchangeRates exchangeRates; + private final Map currencyTotals = new HashMap<>(); + + public CostSummaryCalculator( + Currency userCurrency, + ExchangeRates exchangeRates + ) { + this.userCurrency = userCurrency; + this.exchangeRates = exchangeRates; + } + + public void addCost(Money cost) { + currencyTotals.merge(cost.getCurrency(), cost, Money::add); + } + + public CostSummary summarise() { + var totals = new ArrayList<>(currencyTotals.values()); + totals.sort(comparing(m -> m.getCurrency().getCurrencyCode())); + + CostSummary summary = new CostSummary(userCurrency); + for (var total : totals) { + summary.addLine(exchangeRates.convert(total, userCurrency)); + } + + return summary; + } + + public void reset() { + currencyTotals.clear(); + } +} \ No newline at end of file diff --git a/src/main/java/travelator/itinerary/Interchange.java b/src/main/java/travelator/itinerary/Interchange.java new file mode 100644 index 0000000..3017fa7 --- /dev/null +++ b/src/main/java/travelator/itinerary/Interchange.java @@ -0,0 +1,78 @@ +package travelator.itinerary; + +import travelator.Location; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.Objects; + +public class Interchange { + private final Location arrivalLocation; + private final ZonedDateTime arrivalTime; + private final Location departureLocation; + private final ZonedDateTime departureTime; + + public Interchange(Location arrivalLocation, ZonedDateTime arrivalTime, Location departureLocation, ZonedDateTime departureTime) { + this.arrivalLocation = arrivalLocation; + this.arrivalTime = arrivalTime; + this.departureLocation = departureLocation; + this.departureTime = departureTime; + } + + public static Interchange between(Journey incoming, Journey outgoing) { + return new Interchange( + incoming.getArrivesAt(), incoming.getArrivalTime(), + outgoing.getDepartsFrom(), outgoing.getDepartureTime()); + } + + public Location getArrivalLocation() { + return arrivalLocation; + } + + public ZonedDateTime getArrivalTime() { + return arrivalTime; + } + + public Location getDepartureLocation() { + return departureLocation; + } + + public ZonedDateTime getDepartureTime() { + return departureTime; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Interchange that = (Interchange) o; + return arrivalLocation.equals(that.arrivalLocation) && + arrivalTime.equals(that.arrivalTime) && + departureLocation.equals(that.departureLocation) && + departureTime.equals(that.departureTime); + } + + @Override + public int hashCode() { + return Objects.hash(arrivalLocation, arrivalTime, departureLocation, departureTime); + } + + @Override + public String toString() { + return "RequiredStay{" + + "arrivalLocation=" + arrivalLocation + + ", arrivalTime=" + arrivalTime + + ", departureLocation=" + departureLocation + + ", departureTime=" + departureTime + + '}'; + } + + public boolean isAccommodationRequired() { + boolean waitIsOvernight = + arrivalTime.toLocalDate().isBefore(departureTime.toLocalDate()); + Duration waitDuration = + Duration.between(arrivalTime, departureTime); + + return waitIsOvernight && waitDuration.toHours() >= 6; + } +} \ No newline at end of file diff --git a/src/main/java/travelator/itinerary/Journey.java b/src/main/java/travelator/itinerary/Journey.java new file mode 100644 index 0000000..ab80950 --- /dev/null +++ b/src/main/java/travelator/itinerary/Journey.java @@ -0,0 +1,117 @@ +package travelator.itinerary; + +import travelator.Id; +import travelator.Location; +import travelator.money.Money; + +import java.time.ZonedDateTime; + +/** + * A single journey + */ +public class Journey { + private Id id; + private Location departsFrom, arrivesAt; + private Id operatorId; + private TravelMethod method; + private ZonedDateTime departureTime; // in the timezone of from Location + private ZonedDateTime arrivalTime; // in the timezone of destination + private String operatorClass; + private Money price; + + public Journey() { + } + + public Journey( + Location departsFrom, + Location arrivesAt, + Id operatorId, + TravelMethod method, + ZonedDateTime departureTime, + ZonedDateTime arrivalTime, + String operatorClass, + Money price + ) { + this.id = Id.mint(); + this.departsFrom = departsFrom; + this.arrivesAt = arrivesAt; + this.operatorId = operatorId; + this.method = method; + this.departureTime = departureTime; + this.arrivalTime = arrivalTime; + this.operatorClass = operatorClass; + this.price = price; + } + + public Location getDepartsFrom() { + return departsFrom; + } + + public void setDepartsFrom(Location departsFrom) { + this.departsFrom = departsFrom; + } + + public Location getArrivesAt() { + return arrivesAt; + } + + public void setArrivesAt(Location arrivesAt) { + this.arrivesAt = arrivesAt; + } + + public ZonedDateTime getDepartureTime() { + return departureTime; + } + + public void setDepartureTime(ZonedDateTime departureTime) { + this.departureTime = departureTime; + } + + public ZonedDateTime getArrivalTime() { + return arrivalTime; + } + + public void setArrivalTime(ZonedDateTime arrivalTime) { + this.arrivalTime = arrivalTime; + } + + public TravelMethod getMethod() { + return method; + } + + public void setMethod(TravelMethod method) { + this.method = method; + } + + public Id getOperatorId() { + return operatorId; + } + + public void setOperatorId(Id operatorId) { + this.operatorId = operatorId; + } + + public Money getPrice() { + return price; + } + + public void setPrice(Money price) { + this.price = price; + } + + public String getOperatorClass() { + return operatorClass; + } + + public void setOperatorClass(String operatorClass) { + this.operatorClass = operatorClass; + } + + public Id getId() { + return id; + } + + public void setId(Id id) { + this.id = id; + } +} \ No newline at end of file diff --git a/src/main/java/travelator/itinerary/Operator.java b/src/main/java/travelator/itinerary/Operator.java new file mode 100644 index 0000000..906dc16 --- /dev/null +++ b/src/main/java/travelator/itinerary/Operator.java @@ -0,0 +1,5 @@ +package travelator.itinerary; + +public interface Operator { + String getName(); +} \ No newline at end of file diff --git a/src/main/java/travelator/itinerary/Route.java b/src/main/java/travelator/itinerary/Route.java new file mode 100644 index 0000000..2f424a2 --- /dev/null +++ b/src/main/java/travelator/itinerary/Route.java @@ -0,0 +1,60 @@ +package travelator.itinerary; + +import travelator.Location; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +public class Route { + private final List journeys; + + public Route(List journeys) { + this.journeys = journeys; + } + + public int size() { + return journeys.size(); + } + + public Journey get(int index) { + return journeys.get(index); + } + + public Location getDepartsFrom() { + return get(0).getDepartsFrom(); + } + + public Location getArrivesAt() { + return get(size() - 1).getArrivesAt(); + } + + public Duration getDuration() { + return Duration.between( + get(0).getDepartureTime(), + get(size() - 1).getArrivalTime()); + } + + + public void addCostsTo(CostSummaryCalculator calculator) { + for (var j : journeys) { + calculator.addCost(j.getPrice()); + } + } + + + public List requiredAccommodation() { + var results = new ArrayList(); + + for (int i = 1; i < journeys.size(); i++) { + var interchange = + Interchange.between(journeys.get(i - 1), journeys.get(i)); + + if (interchange.isAccommodationRequired()) { + results.add(interchange); + } + } + + return results; + } +} \ No newline at end of file diff --git a/src/main/java/travelator/itinerary/TravelMethod.java b/src/main/java/travelator/itinerary/TravelMethod.java new file mode 100644 index 0000000..fef6e87 --- /dev/null +++ b/src/main/java/travelator/itinerary/TravelMethod.java @@ -0,0 +1,11 @@ +package travelator.itinerary; + +public enum TravelMethod { + AIR, + SEA, + RAIL, + BUS, + CAR, + CARRIAGE, + CAMEL; +} \ No newline at end of file diff --git a/src/main/java/travelator/money/CurrencyConversion.kt b/src/main/java/travelator/money/CurrencyConversion.kt new file mode 100644 index 0000000..782626f --- /dev/null +++ b/src/main/java/travelator/money/CurrencyConversion.kt @@ -0,0 +1,5 @@ +package travelator.money + +data class CurrencyConversion( + val fromMoney: Money, + val toMoney: Money) \ No newline at end of file diff --git a/src/main/java/travelator/money/ExchangeRates.kt b/src/main/java/travelator/money/ExchangeRates.kt new file mode 100644 index 0000000..5a997dd --- /dev/null +++ b/src/main/java/travelator/money/ExchangeRates.kt @@ -0,0 +1,16 @@ +package travelator.money + +import java.math.BigDecimal +import java.util.* + +interface ExchangeRates { + fun rate(fromCurrency: Currency, toCurrency: Currency): BigDecimal + + @JvmDefault + fun convert(fromMoney: Money, toCurrency: Currency): CurrencyConversion { + val rate = rate(fromMoney.currency, toCurrency) + val toAmount = fromMoney.amount * rate + val toMoney = Money(toAmount, toCurrency) + return CurrencyConversion(fromMoney, toMoney) + } +} \ No newline at end of file diff --git a/src/main/java/travelator/money/ExchangeRatesViaBaseCurrency.kt b/src/main/java/travelator/money/ExchangeRatesViaBaseCurrency.kt new file mode 100644 index 0000000..9096564 --- /dev/null +++ b/src/main/java/travelator/money/ExchangeRatesViaBaseCurrency.kt @@ -0,0 +1,27 @@ +package travelator.money + +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.* + +class ExchangeRatesViaBaseCurrency( + private val baseCurrency: Currency, + vararg rates: Map.Entry +) : ExchangeRates { + + private val rates = rates.map { (k,v) -> k to asRate(v) }.toMap() + + override fun rate(fromCurrency: Currency, toCurrency: Currency): BigDecimal { + val fromRate = baseRate(fromCurrency) + val toRate = baseRate(toCurrency) + return toRate.divide(fromRate, RoundingMode.HALF_EVEN) + } + + private fun baseRate(c: Currency): BigDecimal { + return if (c == baseCurrency) asRate(BigDecimal.ONE) else rates[c]!! + } + + private fun asRate(value: BigDecimal): BigDecimal { + return value.setScale(6, RoundingMode.UNNECESSARY) + } +} \ No newline at end of file diff --git a/src/main/java/travelator/money/Money.kt b/src/main/java/travelator/money/Money.kt new file mode 100644 index 0000000..dd1c189 --- /dev/null +++ b/src/main/java/travelator/money/Money.kt @@ -0,0 +1,60 @@ +package travelator.money + +import java.math.BigDecimal +import java.util.* + +class Money private constructor( + val amount: BigDecimal, + val currency: Currency +) { + override fun equals(other: Any?) = + this === other || + other is Money && + amount == other.amount && + currency == other.currency + + override fun hashCode() = + Objects.hash(amount, currency) + + override fun toString() = + amount.toString() + " " + currency.currencyCode + + fun add(that: Money) = this + that + + operator fun plus(that: Money): Money { + require(currency == that.currency) { + "cannot add Money values of different currencies" + } + return Money(this.amount + that.amount, currency) + } + + companion object : (BigDecimal,Currency) -> Money { + @JvmStatic + fun of(amount: BigDecimal, currency: Currency) = + this(amount, currency) + + override operator fun invoke(amount: BigDecimal, currency: Currency) = + Money( + amount.setScale(currency.defaultFractionDigits), + currency + ) + + @JvmStatic + fun of(amountStr: String, currency: Currency) = + this(amountStr, currency) + + operator fun invoke(amountStr: String, currency: Currency) = + invoke(BigDecimal(amountStr), currency) + + @JvmStatic + fun of(amount: Int, currency: Currency) = + this(amount, currency) + + operator fun invoke(amount: Int, currency: Currency) = + invoke(BigDecimal(amount), currency) + + @JvmStatic + fun zero(userCurrency: Currency) = + invoke(BigDecimal.ZERO, userCurrency) + } +} \ No newline at end of file diff --git a/src/test/java/travelator/itinerary/RouteCostTest.java b/src/test/java/travelator/itinerary/RouteCostTest.java new file mode 100644 index 0000000..189ecba --- /dev/null +++ b/src/test/java/travelator/itinerary/RouteCostTest.java @@ -0,0 +1,97 @@ +package travelator.itinerary; + +import org.junit.jupiter.api.Test; +import travelator.money.ExchangeRates; +import travelator.money.ExchangeRatesViaBaseCurrency; +import travelator.money.Money; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Currency; +import java.util.List; +import java.util.Map; + +import static java.util.Collections.emptyList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static travelator.money.Currencies.*; + + +public class RouteCostTest { + ExchangeRates fx = + new ExchangeRatesViaBaseCurrency( + EUR, + Map.entry(GBP, new BigDecimal("0.8")), + Map.entry(USD, new BigDecimal("1.6")), + Map.entry(JOD, new BigDecimal("100")) + ); + Currency userCurrency = + GBP; + + CostSummaryCalculator calculator = + new CostSummaryCalculator(userCurrency, fx); // <1> + + public CostSummary costSummary(Route r) { + r.addCostsTo(calculator); // <2> + return calculator.summarise(); // <3> + } + + @Test + public void an_empty_route_costs_nothing() throws IOException { + var r = new Route(emptyList()); + + var costs = costSummary(r); + + assertEquals(Money.zero(GBP), costs.getTotal()); + } + + @Test + public void route_in_local_currency() throws IOException { + var r = new Route(List.of( + journeyCosting(Money.of("80", GBP)), + journeyCosting(Money.of("35", GBP)))); + + var costs = costSummary(r); + + assertEquals(Money.of("115", GBP), costs.getTotal()); + } + + @Test + public void route_in_one_foreign_currency() throws IOException { + var r = new Route(List.of( + journeyCosting(Money.of("100", EUR)), + journeyCosting(Money.of("50", EUR)))); + + var costs = costSummary(r); + + assertEquals(Money.of("120", GBP), costs.getTotal()); + } + + @Test + public void route_in_foreign_and_local_currency() throws IOException { + var r = new Route(List.of( + journeyCosting(Money.of("100", EUR)), + journeyCosting(Money.of("50", GBP)))); + + var costs = costSummary(r); + + assertEquals(Money.of("130", GBP), costs.getTotal()); + } + + @Test + public void route_in_multiple_foreign_and_local_currencies() throws IOException { + var r = new Route(List.of( + journeyCosting(Money.of("250", EUR)), + journeyCosting(Money.of("10000", JOD)), + journeyCosting(Money.of("750", GBP)))); + + var costs = costSummary(r); + + assertEquals(Money.of("1030", GBP), costs.getTotal()); + } + + private Journey journeyCosting(Money cost) { + Journey j = new Journey(); + j.setPrice(cost); + return j; + } +} \ No newline at end of file diff --git a/src/test/java/travelator/itinerary/RouteRequiredAccommodationTest.java b/src/test/java/travelator/itinerary/RouteRequiredAccommodationTest.java new file mode 100644 index 0000000..d379de7 --- /dev/null +++ b/src/test/java/travelator/itinerary/RouteRequiredAccommodationTest.java @@ -0,0 +1,122 @@ +package travelator.itinerary; + +import org.junit.jupiter.api.Test; +import travelator.Id; +import travelator.Location; +import travelator.money.Money; + +import java.util.List; + +import static java.util.Collections.emptyList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static travelator.itinerary.TestData.*; +import static travelator.money.Currencies.EUR; + +public class RouteRequiredAccommodationTest { + @Test + public void an_empty_route_does_not_need_accommodation() { + Route emptyRoute = new Route(emptyList()); + + assertEquals(emptyList(), emptyRoute.requiredAccommodation()); + } + + @Test + public void a_route_of_one_journey_does_not_need_accommodation() { + Route r = routeOf( + journey( + Jakarta_Tanjung_Priok_Ferry_Terminal, "2020-08-06 23:59 Asia/Jakarta", + Batam_Batu_Ampar_Ferry_Terminal, "2020-08-08 05:40 Asia/Jakarta")); + assertEquals(emptyList(), r.requiredAccommodation()); + } + + @Test + public void a_route_of_two_journeys_does_not_need_accommodation_if_continue_on_same_day() { + Route r = routeOf( + journey( + Batam_Sekupang_Ferry_Terminal, "2020-08-08 07:00 Asia/Jakarta", + Singapore_Harbourfront_Centre, "2020-08-08 06:50 Asia/Singapore"), + journey( + Singapore_Harbourfront_Centre, "2020-08-08 07:00 Asia/Singapore", + Johor_Bahru_Sentral, "2020-08-08 08:00 Asia/Kuala_Lumpur")); + + assertEquals(emptyList(), r.requiredAccommodation()); + } + + @Test + public void a_route_of_two_journeys_that_requires_accommodation() { + Route r = routeOf( + journey( + Johor_Bahru_Sentral, "2020-08-10 10:10 Asia/Kuala_Lumpur", + Kuala_Lumpur_Sentral, "2020-08-10 17:36 Asia/Kuala_Lumpur"), + journey( + Kuala_Lumpur_Sentral, "2020-08-11 10:28 Asia/Kuala_Lumpur", + Bangkok, "2020-08-12 09:10 Asia/Bangkok")); + + assertEquals( + List.of(new Interchange( + Kuala_Lumpur_Sentral, dateTime("2020-08-10 17:36 Asia/Kuala_Lumpur"), + Kuala_Lumpur_Sentral, dateTime("2020-08-11 10:28 Asia/Kuala_Lumpur"))), + r.requiredAccommodation()); + } + + @Test + public void a_route_of_two_journeys_that_has_a_short_overnight_wait_so_does_not_need_accommodation() { + Route r = routeOf( + journey( + Johor_Bahru_Sentral, "2020-08-10 10:10 Asia/Kuala_Lumpur", + Kuala_Lumpur_Sentral, "2020-08-10 23:30 Asia/Kuala_Lumpur"), + journey( + Kuala_Lumpur_Sentral, "2020-08-11 00:30 Asia/Kuala_Lumpur", + Bangkok, "2020-08-12 09:10 Asia/Bangkok")); + + assertEquals(emptyList(), r.requiredAccommodation()); + } + + @Test + public void a_route_that_requires_accommodation_for_multiple_nights() { + Route r = routeOf( + journey( + Jakarta_Tanjung_Priok_Ferry_Terminal, "2020-08-06 23:59 Asia/Jakarta", + Batam_Batu_Ampar_Ferry_Terminal, "2020-08-08 05:40 Asia/Jakarta"), + journey( + Batam_Sekupang_Ferry_Terminal, "2020-08-08 07:00 Asia/Jakarta", + Singapore_Harbourfront_Centre, "2020-08-08 06:50 Asia/Singapore"), + journey( + Johor_Bahru_Sentral, "2020-08-10 10:10 Asia/Kuala_Lumpur", + Kuala_Lumpur_Sentral, "2020-08-10 17:36 Asia/Kuala_Lumpur"), + journey( + Kuala_Lumpur_Sentral, "2020-08-11 10:28 Asia/Kuala_Lumpur", + Bangkok, "2020-08-12 09:10 Asia/Bangkok")); + + assertEquals( + List.of( + new Interchange( + Singapore_Harbourfront_Centre, dateTime("2020-08-08 06:50 Asia/Singapore"), + Johor_Bahru_Sentral, dateTime("2020-08-10 10:10 Asia/Kuala_Lumpur")), + new Interchange( + Kuala_Lumpur_Sentral, dateTime("2020-08-10 17:36 Asia/Kuala_Lumpur"), + Kuala_Lumpur_Sentral, dateTime("2020-08-11 10:28 Asia/Kuala_Lumpur"))), + r.requiredAccommodation()); + } + + private Journey journey(Location origin, + String departureTimeStr, + Location destination, + String arrivalTimeStr + ) { + return new Journey( + origin, + destination, + Id.of("example-operator"), + TravelMethod.RAIL, + dateTime(departureTimeStr), + dateTime(arrivalTimeStr), + "Standard", + Money.of(100, EUR) + ); + } + + public static Route routeOf(Journey... journeys) { + return new Route(List.of(journeys)); + } +} \ No newline at end of file diff --git a/src/test/java/travelator/itinerary/TestData.java b/src/test/java/travelator/itinerary/TestData.java new file mode 100644 index 0000000..313d773 --- /dev/null +++ b/src/test/java/travelator/itinerary/TestData.java @@ -0,0 +1,63 @@ +package travelator.itinerary; + +import travelator.Id; +import travelator.Location; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; + +import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE; +import static java.time.format.DateTimeFormatter.ISO_LOCAL_TIME; +import static java.util.Locale.ROOT; + +public class TestData { + public static Location Jakarta_Tanjung_Priok_Ferry_Terminal = new Location( + Id.of("TestData.Jakarta_Tanjung_Priok_Ferry_Terminal"), + "Jakarta Tanjung Priok", + "Jakarta Tanjung Priok"); + + public static Location Batam_Batu_Ampar_Ferry_Terminal = new Location( + Id.of("TestData.Batam_Batu_Ampar_Ferry_Terminal"), + "Batam Batu Ampar", + "Batam Batu Ampar"); + + public static Location Batam_Sekupang_Ferry_Terminal = new Location( + Id.of("TestData.Batam_Sekupang_Ferry_Terminal"), + "Batam Sekupang", + "Batam Sekupang"); + + public static Location Singapore_Harbourfront_Centre = new Location( + Id.of("TestData.Singapore_Harbourfront_Centre"), + "Singapore Harbourfront Centre", + "Singapore Harbourfront Centre"); + + public static Location Johor_Bahru_Sentral = new Location( + Id.of("TestData.Johor_Bahru_Sentral"), + "Johor Bahru Sentral", + "Johor Bahru Central Station"); + + public static Location Kuala_Lumpur_Sentral = new Location( + Id.of("TestData.Kuala_Lumpur_Sentral"), + "Kuala Lumpur Sentral", + "Kuala Lumpur Central Station"); + + public static Location Bangkok = new Location( + Id.of("TestData.Bangkok"), + "Bangkok", + "Bangkok"); + + public static ZonedDateTime dateTime(String s) { + return ZonedDateTime.parse(s, formatter); + } + + private static final DateTimeFormatter formatter = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .parseCaseInsensitive() + .append(ISO_LOCAL_DATE) + .appendLiteral(" ") + .append(ISO_LOCAL_TIME) + .appendLiteral(" ") + .appendZoneOrOffsetId() + .toFormatter(ROOT); +} \ No newline at end of file diff --git a/src/test/java/travelator/money/Currencies.java b/src/test/java/travelator/money/Currencies.java new file mode 100644 index 0000000..fe7b199 --- /dev/null +++ b/src/test/java/travelator/money/Currencies.java @@ -0,0 +1,16 @@ +package travelator.money; + +import java.util.Currency; + +public class Currencies { + // These all have 100 minor units per major unit + public static final Currency GBP = Currency.getInstance("GBP"); + public static final Currency USD = Currency.getInstance("USD"); + public static final Currency EUR = Currency.getInstance("EUR"); + + // JOD has 1000 minor units per major unit + public static final Currency JOD = Currency.getInstance("JOD"); + + // JPY has no minor units + public static final Currency JPY = Currency.getInstance("JPY"); +} \ No newline at end of file diff --git a/src/test/java/travelator/money/ExchangeRatesViaBaseCurrencyTest.java b/src/test/java/travelator/money/ExchangeRatesViaBaseCurrencyTest.java new file mode 100644 index 0000000..96699d3 --- /dev/null +++ b/src/test/java/travelator/money/ExchangeRatesViaBaseCurrencyTest.java @@ -0,0 +1,47 @@ +package travelator.money; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static travelator.money.Currencies.*; + +public class ExchangeRatesViaBaseCurrencyTest { + private final ExchangeRates fx = new ExchangeRatesViaBaseCurrency( + EUR, + Map.entry(GBP, new BigDecimal("0.8")), + Map.entry(JOD, new BigDecimal("100")) + ); + + @Test + public void same_currency() throws IOException { + assertEquals( + Money.of("125", EUR), + fx.convert(Money.of("125", EUR), EUR).getToMoney()); + } + + @Test + public void one_foreign_currency() throws IOException { + assertConversionBothWays(Money.of("100", EUR), Money.of("80", GBP)); + } + + @Test + public void two_foreign_currencies() throws IOException { + assertConversionBothWays(Money.of("80", GBP), Money.of("10000", JOD)); + } + + public void assertConversionBothWays(Money m1, Money m2) throws IOException { + assertConversion(m1, m2); + assertConversion(m2, m1); + } + + public void assertConversion(Money fromMoney, Money toMoney) throws IOException { + assertEquals( + toMoney, + fx.convert(fromMoney, toMoney.getCurrency()).getToMoney(), + () -> fromMoney.getCurrency() + " -> " + toMoney.getCurrency()); + } +} \ No newline at end of file diff --git a/src/test/java/travelator/money/MoneyTest.kt b/src/test/java/travelator/money/MoneyTest.kt new file mode 100644 index 0000000..b835713 --- /dev/null +++ b/src/test/java/travelator/money/MoneyTest.kt @@ -0,0 +1,35 @@ +package travelator.money + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import travelator.money.Currencies.* + +class MoneyTest { + @Test + fun representation() { + assertEquals("17.25 GBP", Money.of("17.25", GBP).toString()) + assertEquals("20.00 EUR", Money.of("20", EUR).toString()) + assertEquals("1.500 JOD", Money.of("1.5", JOD).toString()) + assertEquals("1200 JPY", Money.of("1200", JPY).toString()) + } + + @Test + fun adding() { + assertEquals( + Money(275, EUR), + Money(200, EUR) + Money(75, EUR) + ) + assertEquals( + Money(266, GBP), + Money(266, GBP) + Money.zero(GBP) + ) + } + + @Test + fun cannot_add_money_of_different_currencies() { + assertThrows(IllegalArgumentException::class.java) { + Money(1, GBP) + Money(2, EUR) + } + } +} \ No newline at end of file