diff --git a/README_DEV.md b/README_DEV.md new file mode 100644 index 0000000..a730097 --- /dev/null +++ b/README_DEV.md @@ -0,0 +1,16 @@ +# Documentación +No he comentado el código porque creo que es bastante sencillo. No obstante los tests creo que explican bastante sobre +lo que hace cada función. + + +# Docker +No tengo muy claro como se esperaba la creación del contenedor de docker. Así que lo he hecho muy sencillo. +Para crear la imagen (tras crear el fatjar) + +```bash +docker build -t solution:1.0.0 -f src/main/docker/Dockerfile . +``` +Para ejecutar la aplicación +```bash +docker run --rm -it -v ${PWD}/data:/opt/data solution:1.0.0 +``` diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..bc95779 --- /dev/null +++ b/build.sbt @@ -0,0 +1,21 @@ +import Dependencies._ + +organization := "inspide" +name := "solution" +version := "1.0.0" + +scalaVersion := "2.12.10" + +assemblyJarName in assembly := "solution.jar" + +assemblyMergeStrategy in assembly := { + case PathList("META-INF", xs @ _*) => MergeStrategy.discard + case x => MergeStrategy.first +} + +resolvers += "Bintray" at "https://jcenter.bintray.com/" +resolvers += "Osgeo Repo" at "http://download.osgeo.org/webdav/geotools/" +resolvers += "Boundless" at "http://repo.boundlessgeo.com/main" + +libraryDependencies ++= coreDependencies +dependencyOverrides ++= coreDependencyOverrides diff --git a/project/Dependencies.scala b/project/Dependencies.scala new file mode 100644 index 0000000..45f3c50 --- /dev/null +++ b/project/Dependencies.scala @@ -0,0 +1,27 @@ +import sbt._ + +object Dependencies { + val GPXParserVersion = "1.12" + val GeotoolsVersion = "22.3" + + val ScalatestVersion = "3.1.0" + + val coreMainDependencies = Seq( + "me.himanshusoni.gpxparser" % "gpx-parser" % GPXParserVersion % Compile, + "org.geotools" % "gt-referencing" % GeotoolsVersion % Compile, + + /** + * The next line avoids a problem downloading this transitive dependency. + * Please check https://stackoverflow.com/a/26993223 + */ + "javax.media" % "jai_core" % "1.1.3" % Compile from "http://download.osgeo.org/webdav/geotools/javax/media/jai_core/1.1.3/jai_core-1.1.3.jar" + ) + + val testDependencies = Seq( + "org.scalatest" %% "scalatest" % ScalatestVersion % Test, + ) + + val coreDependencies = coreMainDependencies ++ testDependencies + val coreDependencyOverrides = Seq() + +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..c0bab04 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.2.8 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..c9c0554 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.10") \ No newline at end of file diff --git a/src/main/docker/Dockerfile b/src/main/docker/Dockerfile new file mode 100644 index 0000000..73dd18e --- /dev/null +++ b/src/main/docker/Dockerfile @@ -0,0 +1,4 @@ +FROM azul/zulu-openjdk:8u242 +WORKDIR /opt/ +COPY target/scala-2.12/solution.jar . +ENTRYPOINT ["java", "-jar", "solution.jar", "data"] \ No newline at end of file diff --git a/src/main/scala/inspide/test/CSV.scala b/src/main/scala/inspide/test/CSV.scala new file mode 100644 index 0000000..dcb6642 --- /dev/null +++ b/src/main/scala/inspide/test/CSV.scala @@ -0,0 +1,13 @@ +package inspide.test + +object CSV { + + implicit val ordering = Ordering[Double].reverse + + val CsvHead = "id\ttotalNumberOfPoints\ttotalDistance\tavgDistance\tminDistance\tmaxDistance\n" + + val results2csv = (results: Results) => results.productIterator.mkString("\t") + + val toCSV = (seqOfResults: Seq[Results]) => CsvHead + seqOfResults.sortBy(_.totalDistance).map(results2csv).mkString("\n") + +} diff --git a/src/main/scala/inspide/test/DistanceCalculator.scala b/src/main/scala/inspide/test/DistanceCalculator.scala new file mode 100644 index 0000000..600c18b --- /dev/null +++ b/src/main/scala/inspide/test/DistanceCalculator.scala @@ -0,0 +1,28 @@ +package inspide.test + +import org.geotools.referencing.GeodeticCalculator + + +object DistanceCalculator { + + private val fixDecimals = (precission: Int) => (value: Double) => BigDecimal(value).setScale(precission, BigDecimal.RoundingMode.HALF_UP).toDouble + private val fixTo3Decimals = fixDecimals(3) + + val calculateDistance = (segment: Segment) => { + val gc = new GeodeticCalculator() + gc.setStartingGeographicPoint(segment.from.lon, segment.from.lat) + gc.setDestinationGeographicPoint(segment.to.lon, segment.to.lat) + fixTo3Decimals(gc.getOrthodromicDistance) + } + + val toSegments = (points: Seq[Point]) => points.zip(points.tail).map(p => Segment(p._1, p._2)) + + val calculateTotalDistance = (segments: Seq[Segment]) => fixTo3Decimals(segments.map(calculateDistance).sum) + + val calculateAverageDistance = (segments: Seq[Segment]) => fixTo3Decimals(calculateTotalDistance(segments) / segments.length) + + val getMinimunDistance = (segments: Seq[Segment]) => fixTo3Decimals(segments.map(calculateDistance).min) + + val getMaximunDistance = (segments: Seq[Segment]) => fixTo3Decimals(segments.map(calculateDistance).max) + +} diff --git a/src/main/scala/inspide/test/GPXParser.scala b/src/main/scala/inspide/test/GPXParser.scala new file mode 100644 index 0000000..7b0f1a6 --- /dev/null +++ b/src/main/scala/inspide/test/GPXParser.scala @@ -0,0 +1,60 @@ +package inspide.test + +import java.io.{File, FileInputStream, InputStream} + +import inspide.test.DistanceCalculator._ +import me.himanshusoni.gpxparser.modal.{GPX, Track, TrackSegment} +import me.himanshusoni.gpxparser.{GPXParser => GPXFileParser} + +import scala.collection.JavaConverters._ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +object GPXParser { + + private val createInputStream = (file: File) => Future { + new FileInputStream(file) + } + + private val toPoints = (trackSegment: TrackSegment) => trackSegment.getWaypoints.asScala.toList.map(wp => Point(wp.getLatitude, wp.getLongitude)) + + private val toListOfPoints = (track: Track) => track.getTrackSegments.asScala.toList.map(toPoints).head + + private val toListOfTrackSegments = (tracks: Seq[Track]) => tracks.map(toListOfPoints).head + + private val toListOfTracks = (gpx: GPX) => gpx.getTracks.asScala.toList + + private val gpxParser = new GPXFileParser() + + /** + * This must be done because GPXFileParser is not thread-safe. Internally it uses a SimpleDateFormat that is created + * statically. SimpleDataFormat is not thread-safe and the usage of Futures ends up with a lot of stack traces. I would + * use Task of Monix or IO from Cats instead of plain Scala Futures. But I wanted to strictly stick to the definition + * of this problem, so I have not use any other libraries. + * The conflictive line is at [[https://github.com/himanshu-soni/gpx-parser/blob/master/src/main/java/me/himanshusoni/gpxparser/BaseGPX.java#L15]] + */ + private val parseGPX = (inputStream: InputStream) => Future { + gpxParser.synchronized { + gpxParser.parseGPX(inputStream) + } + } + + val getPoints = (file: File) => for { + is <- createInputStream(file) + gpx <- parseGPX(is) + tracks <- Future(toListOfTracks(gpx)) + trackPoints <- Future(toListOfTrackSegments(tracks)) + _ <- Future(is.close()) + } yield trackPoints + + val getResults = (file: File) => for { + points <- getPoints(file) + segments <- Future(toSegments(points)) + totalPoints <- Future(points.size) + totalDistance <- Future(calculateTotalDistance(segments)) + avgDistance <- Future(calculateAverageDistance(segments)) + minDistance <- Future(getMinimunDistance(segments)) + maxDistance <- Future(getMaximunDistance(segments)) + } yield Results(file.getName, totalPoints, totalDistance, avgDistance, minDistance, maxDistance) + +} diff --git a/src/main/scala/inspide/test/Output.scala b/src/main/scala/inspide/test/Output.scala new file mode 100644 index 0000000..54c42c0 --- /dev/null +++ b/src/main/scala/inspide/test/Output.scala @@ -0,0 +1,7 @@ +package inspide.test + +trait Output { + + def print(s: String) = Console.print(s) + +} diff --git a/src/main/scala/inspide/test/SolutionApp.scala b/src/main/scala/inspide/test/SolutionApp.scala new file mode 100644 index 0000000..ea7c543 --- /dev/null +++ b/src/main/scala/inspide/test/SolutionApp.scala @@ -0,0 +1,24 @@ +package inspide.test + +import java.nio.file.Paths + +import scala.concurrent.{Await, Future} +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.Duration + +class SolutionApp(directory: String) extends Output { + + val mainFn = for { + gpxFiles <- Future(Paths.get(directory).toFile.listFiles().toList) + results <- Future.sequence(gpxFiles.map(GPXParser.getResults)) + csv <- Future(CSV.toCSV(results)) + out <- Future(print(csv)) + } yield out + +} + +object SolutionApp extends App { + + Await.result(new SolutionApp(args(0)).mainFn, Duration.Inf) + +} diff --git a/src/main/scala/inspide/test/package.scala b/src/main/scala/inspide/test/package.scala new file mode 100644 index 0000000..cd899bb --- /dev/null +++ b/src/main/scala/inspide/test/package.scala @@ -0,0 +1,10 @@ +package inspide + +package object test { + + case class Point(lat: Double, lon: Double) + case class Segment(from: Point, to: Point) + + case class Results(id:String, totalNumberOfPoints: Int, totalDistance: Double, avgDistance: Double, minDistance: Double, maxDistance: Double) + +} diff --git a/src/test/resources/test.gpx b/src/test/resources/test.gpx new file mode 100644 index 0000000..8b8c28a --- /dev/null +++ b/src/test/resources/test.gpx @@ -0,0 +1,37 @@ + + + + Wikiloc - Panoramica madrid + + vmoscardo + + vmoscardo on Wikiloc + + + + Panoramica madrid on Wikiloc + + + + + Panoramica madrid + + + + + 877.089 + + + + 842.006 + + + + 895.026 + + + + + \ No newline at end of file diff --git a/src/test/scala/inspide/test/CSVSpec.scala b/src/test/scala/inspide/test/CSVSpec.scala new file mode 100644 index 0000000..59f0792 --- /dev/null +++ b/src/test/scala/inspide/test/CSVSpec.scala @@ -0,0 +1,23 @@ +package inspide.test + +import org.scalatest.GivenWhenThen +import org.scalatest.featurespec.AnyFeatureSpecLike + +class CSVSpec extends AnyFeatureSpecLike with GivenWhenThen { + + private val ExpectedResults = Seq( + Results("f1.gpx", 1, 2, 3, 4, 5), + Results("f2.gpx", 6, 7, 8, 9, 10), + ) + + Scenario("Create CSV") { + When("the results are formatted as CSV") + val csv = CSV.toCSV(ExpectedResults) + Then("the csv is the expected") + assert(csv == + StringContext.treatEscapes("""id\ttotalNumberOfPoints\ttotalDistance\tavgDistance\tminDistance\tmaxDistance + |f2.gpx\t6\t7.0\t8.0\t9.0\t10.0 + |f1.gpx\t1\t2.0\t3.0\t4.0\t5.0""".stripMargin)) + } + +} diff --git a/src/test/scala/inspide/test/DistanceCalculatorSpec.scala b/src/test/scala/inspide/test/DistanceCalculatorSpec.scala new file mode 100644 index 0000000..3b52022 --- /dev/null +++ b/src/test/scala/inspide/test/DistanceCalculatorSpec.scala @@ -0,0 +1,78 @@ +package inspide.test + +import org.scalatest.GivenWhenThen +import org.scalatest.featurespec.AnyFeatureSpecLike + +/** + * The distance fixtures have been calculated at https://edwilliams.org/gccalc.htm + */ +class DistanceCalculatorSpec extends AnyFeatureSpecLike with GivenWhenThen { + + import inspide.test.DistanceCalculator._ + + Feature("Calculate distances") { + + Scenario("Distance between points") { + Given("three points") + val point1 = Point(40.492861, -3.593716) + val point2 = Point(40.492528, -3.593178) + val point3 = Point(40.493062, -3.593395) + When("the distance between two of them is calculated") + val distanceFrom1To2 = calculateDistance(Segment(point1, point2)) + val distanceFrom2To3 = calculateDistance(Segment(point2, point3)) + Then("the results are the expected") + assert(distanceFrom1To2 == 58.716) + assert(distanceFrom2To3 == 62.086) + } + + Scenario("Total distance between points") { + Given("three points") + val point1 = Point(40.492861, -3.593716) + val point2 = Point(40.492528, -3.593178) + val point3 = Point(40.493062, -3.593395) + When("the total distance is calculated") + val segments = toSegments(Seq(point1, point2, point3)) + val distance = calculateTotalDistance(segments) + Then("the results are the expected") + assert(distance == 120.802) + } + + Scenario("Average distance between points") { + Given("three points") + val point1 = Point(40.492861, -3.593716) + val point2 = Point(40.492528, -3.593178) + val point3 = Point(40.493062, -3.593395) + When("the average distance is calculated") + val segments = toSegments(Seq(point1, point2, point3)) + val avgDistance = calculateAverageDistance(segments) + Then("the results are the expected") + assert(avgDistance == 60.401) + } + + Scenario("Minimum distance between points") { + Given("three points") + val point1 = Point(40.492861, -3.593716) + val point2 = Point(40.492528, -3.593178) + val point3 = Point(40.493062, -3.593395) + When("the minimum distance is calculated") + val segments = toSegments(Seq(point1, point2, point3)) + val minDistance = getMinimunDistance(segments) + Then("the results are the expected") + assert(minDistance == 58.716) + } + + Scenario("Maximum distance between points") { + Given("three points") + val point1 = Point(40.492861, -3.593716) + val point2 = Point(40.492528, -3.593178) + val point3 = Point(40.493062, -3.593395) + When("the maximum distance is calculated") + val segments = toSegments(Seq(point1, point2, point3)) + val maxDistance = getMaximunDistance(segments) + Then("the results are the expected") + assert(maxDistance == 62.086) + } + + } + +} diff --git a/src/test/scala/inspide/test/GPXParserSpec.scala b/src/test/scala/inspide/test/GPXParserSpec.scala new file mode 100644 index 0000000..d825a6b --- /dev/null +++ b/src/test/scala/inspide/test/GPXParserSpec.scala @@ -0,0 +1,53 @@ +package inspide.test + +import java.nio.file.Paths + + +import org.scalatest.GivenWhenThen +import org.scalatest.featurespec.AnyFeatureSpecLike + +import scala.concurrent.Await +import scala.concurrent.duration._ + + +class GPXParserSpec extends AnyFeatureSpecLike with GivenWhenThen { + + private val ExpectedSegments = List( + Point(40.492861, -3.593716), + Point(40.492528, -3.593178), + Point(40.493062, -3.593395) + ) + + private val ExpectedResults = Results( + "test.gpx", + 3, + 120.802, + 60.401, + 58.716, + 62.086 + ) + + Feature("Parse GPX files") { + + Scenario("Parse GPX file") { + Given("a GPX file") + val file = Paths.get("src/test/resources", "test.gpx").toFile + When("the points are required") + val points = GPXParser.getPoints(file) + Then("the segments are the expected") + assert(Await.result(points, 5 seconds) == ExpectedSegments) + } + + Scenario("Get results for file") { + Given("a GPX file") + val file = Paths.get("src/test/resources", "test.gpx").toFile + When("the results are required") + val results = GPXParser.getResults(file) + + Then("the results are the expected") + assert(Await.result(results, 5 seconds) == ExpectedResults) + } + + } + +} diff --git a/src/test/scala/inspide/test/SolutionAppSpec.scala b/src/test/scala/inspide/test/SolutionAppSpec.scala new file mode 100644 index 0000000..ebd5952 --- /dev/null +++ b/src/test/scala/inspide/test/SolutionAppSpec.scala @@ -0,0 +1,24 @@ +package inspide.test + + +import org.scalatest.featurespec.AnyFeatureSpecLike + +import scala.concurrent.Await +import scala.concurrent.duration._ + +class SolutionAppSpec extends AnyFeatureSpecLike { + + trait FakeOutput extends Output { + var out = "" + override def print(str: String) = out = out + str + } + + Scenario("Generate results") { + val app = new SolutionApp("src/test/resources") with FakeOutput + Await.result(app.mainFn, 30 seconds) + assert(app.out == StringContext.treatEscapes( + """id\ttotalNumberOfPoints\ttotalDistance\tavgDistance\tminDistance\tmaxDistance + |test.gpx\t3\t120.802\t60.401\t58.716\t62.086""".stripMargin)) + } + +}