From 0b3701b649d8d8628ea736314e9d2efa83881cc2 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Tue, 28 Apr 2020 20:09:28 +0100 Subject: [PATCH 01/25] Initial testing work and setup --- README.md | 9 +++++++-- composer.json | 7 +++++-- phpunit.xml | 22 ++++++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 phpunit.xml diff --git a/README.md b/README.md index 921670d..fc1460f 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,12 @@ In the end, you should have a CSV file that looks something like this: #### The construction -The library is used by first (a) using the composer class autoloader and then 'using' the class `use CobaltGrid\VatsimStandStatus\StandStatus;` or (b) using require/include to import the class into the current file `require_once('...../src/StandStatus.php')` +The best way to use this library is by using Composer Autoloader: +``` + require('./vendor/autoload.php'); + use CobaltGrid\VatsimStandStatus\StandStatus; +``` + Then, an instance of the class must be made: ``` @@ -106,7 +111,7 @@ $StandStatus = new StandStatus($airportICAO, $airportStandsFile, $airportLatCoor Here is an example: -`$StandStatus = new StandStatus("EGKK", dirname(__FILE__) . "/standData/egkkstands.csv", 51.148056, -0.190278, 3);` +`$StandStatus = new StandStatus("EGKK", dirname(__FILE__) . "/standData/egkkstands.csv", 51.148056, -0.190278, true, 3);` Once this step has done, the data file is downloaded and processed, and stands with aircraft close enough to them, and that fit within the requirements, are marked as occupied by the associated aircraft. All of the aircraft's data is assigned to the stand, allowing you to access many variables from the network data: ``` diff --git a/composer.json b/composer.json index d68976b..1f29f0d 100644 --- a/composer.json +++ b/composer.json @@ -8,8 +8,8 @@ "authors" : [ { "name" : "Alex Toff", - "email" : "admin@cobaltgrid.com", - "homepage" : "https://www.cobaltgrid.com" + "email" : "alex.toff@cobaltgrid.com", + "homepage" : "https://cobaltgrid.com" } ], "require": { @@ -19,5 +19,8 @@ "psr-4": { "CobaltGrid\\VatsimStandStatus\\": "src/" } + }, + "require-dev": { + "phpunit/phpunit": "^9.1" } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..66bad86 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests + + + + + + src + + + From c3995876e9e0b71bdffd9b945212f2a14835a005 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Wed, 29 Apr 2020 18:54:42 +0100 Subject: [PATCH 02/25] Mass refactor to OOP style. Fix PSR compliance. --- .gitignore | 1 + examples/egkkStands.php | 255 ++--- examples/egllStands.php | 128 --- examples/standData/egllstands.csv | 240 ----- src/Aircraft.php | 70 ++ .../CoordinateOutOfBoundsException.php | 8 + src/Exceptions/InvalidStandException.php | 8 + .../UnableToLoadStandDataFileException.php | 8 + .../UnableToParseStandDataException.php | 8 + src/Libraries/CAACoordinateConverter.php | 26 + src/Libraries/CoordinateConverter.php | 46 + src/Stand.php | 113 +++ src/StandStatus.php | 915 +++++++++--------- 13 files changed, 871 insertions(+), 955 deletions(-) delete mode 100644 examples/egllStands.php delete mode 100644 examples/standData/egllstands.csv create mode 100644 src/Aircraft.php create mode 100644 src/Exceptions/CoordinateOutOfBoundsException.php create mode 100644 src/Exceptions/InvalidStandException.php create mode 100644 src/Exceptions/UnableToLoadStandDataFileException.php create mode 100644 src/Exceptions/UnableToParseStandDataException.php create mode 100644 src/Libraries/CAACoordinateConverter.php create mode 100644 src/Libraries/CoordinateConverter.php create mode 100644 src/Stand.php diff --git a/.gitignore b/.gitignore index 7579f74..fa36fe5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ vendor composer.lock +.idea diff --git a/examples/egkkStands.php b/examples/egkkStands.php index 8fc77a8..1a2a8ef 100644 --- a/examples/egkkStands.php +++ b/examples/egkkStands.php @@ -4,128 +4,141 @@ require_once '../vendor/autoload.php'; -$StandStatus = new StandStatus("EGKK", dirname(__FILE__) . "/standData/egkkstands.csv", 51.148056, -0.190278); - +$StandStatus = new StandStatus(dirname(__FILE__) . "/standData/egkkstands.csv", 51.148056, -0.190278, null, StandStatus::COORD_FORMAT_CAA); ?> - - - - - - + + + + + + + + + + + + + -
- - - - - - - - occupiedStands() as $stand){ - ?> - - - - - -
StandOccupied
- - - - - - - - -allStands() as $stand) { - - ?> - - - - - - - -
StandLatLong
- -
-
    - aircraftSearchResults as $pilot){ - echo "
  • " . $pilot['callsign'] . "
  • " . $pilot['latitude'] . " BY " . $pilot['longitude']; - } - ?> -
+ + + + + +
+
+
+
Occupied Stands
+ + + + + + occupiedStands() as $stand) { + ?> + + + + + +
StandOccupied By
getName() ?>occupier->callsign ?>
+
+
+
+
+
+
+
+
All Stands
+ + + + + + + + + + + allStands() as $stand) { ?> + + + + + + + + +
StandLatitudeLongitudeOccupier
getName() ?>latitude ?>longitude ?>isOccupied() ? $stand->occupier->callsign : null ?>
+
+
+
Aircraft On The Ground
+
+ getAllAircraft() as $pilot) { + if($pilot->onStand()){ + echo "
{$pilot->callsign} ({$pilot->latitude},{$pilot->longitude}) (Stand {$pilot->getStandIndex()})
"; + }else{ + echo "
{$pilot->callsign} ({$pilot->latitude},{$pilot->longitude}) (Not on stand)
"; + } + } + ?> +
+
+
+ + diff --git a/examples/egllStands.php b/examples/egllStands.php deleted file mode 100644 index 7f94247..0000000 --- a/examples/egllStands.php +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - - -
- - - - - - - occupiedStands as $stand){ - ?> - - - - - -
StandOccupied
stands[$stand]['occupied']['callsign'] ?>
- - - - - - - - -stands as $stand) { - - ?> - - - - - - - -
StandLatLong
- -
-
    - aircraftSearchResults as $pilot){ - echo "
  • " . $pilot['callsign'] . "
  • " . $pilot['latitude'] . " BY " . $pilot['longitude']; - } - ?> -
-
diff --git a/examples/standData/egllstands.csv b/examples/standData/egllstands.csv deleted file mode 100644 index cb2df1f..0000000 --- a/examples/standData/egllstands.csv +++ /dev/null @@ -1,240 +0,0 @@ -id,latcoord,longcoord -122,512818.92N,0002647.53W -139,512818.14N,0002644.55W -141,512819.47N,0002642.43W -216,512813.31N,0002646.75W -217,512811.87N,0002646.74W -218,512809.72N,0002646.72W -218L,512809.04N,0002646.71W -218R,512810.43N,0002646.72W -219,512806.91N,0002646.69W -220,512805.54N,0002646.68W -221,512804.69N,0002655.73W -221L,512804.69N,0002656.77W -221R,512804.69N,0002654.54W -223,512804.68N,0002659.25W -224,512804.67N,0002701.58W -225,512804.66N,0002704.07W -226,512804.65N,0002706.53W -231,512826.94N,0002641.43W -232,512824.22N,0002641.41W -233,512821.58N,0002641.39W -233L,512822.32N,0002641.39W -233R,512821.04N,0002641.38W -236,512812.62N,0002641.31W -236L,512813.28N,0002641.32W -236R,512811.90N,0002641.31W -238,512809.74N,0002641.29W -239,512807.10N,0002641.27W -241,512807.21N,0002628.72W -242,512809.78N,0002628.74W -243,512812.66N,0002628.76W -243L,512811.99N,0002628.76W -243R,512813.38N,0002628.77W -244,512815.54N,0002628.79W -246,512818.74N,0002628.81W -246L,512818.07N,0002628.81W -246R,512819.46N,0002628.82W -247,512821.62N,0002628.84W -247L,512820.88N,0002628.83W -247R,512822.33N,0002628.84W -248,512824.26N,0002628.86W -249,512826.98N,0002628.88W -251,512807.64N,0002623.30W -252,512810.04N,0002623.32W -253,512812.68N,0002623.34W -253L,512813.34N,0002623.34W -253R,512811.95N,0002623.33W -254,512815.55N,0002623.36W -254L,512816.22N,0002623.36W -254R,512814.83N,0002623.35W -255,512819.07N,0002623.39W -255L,, -255R,, -256,512821.40N,0002623.41W -257,512823.79N,0002623.43W -258,512826.75N,0002623.45W -258L,512827.46N,0002623.46W -258R,512826.01N,0002623.44W -301,512804.68N,0002716.02W -303,512804.66N,0002720.63W -303L,512804.66N,0002721.82W -303R,512804.67N,0002719.63W -305,512804.65N,0002725.24W -305L,512804.64N,0002726.43W -305R,512804.65N,0002724.24W -307,512804.63N,0002729.85W -309,512807.81N,0002735.03W -311,512809.02N,0002736.35W -313,512807.06N,0002739.59W -316,512805.70N,0002750.57W -317,512807.74N,0002752.80W -318,512812.15N,0002744.72W -319,512809.77N,0002755.03W -320,512814.18N,0002746.95W -321,512811.81N,0002757.26W -322,512816.22N,0002749.18W -323,512813.32N,0002740.59W -325,512815.19N,0002742.65W -326,512823.20N,0002738.90W -327,512817.06N,0002744.70W -328,512825.06N,0002740.95W -329,512818.93N,0002746.74W -330,512826.92N,0002742.99W -331,512820.80N,0002748.79W -332,512829.17N,0002745.61W -334,512829.16N,0002750.34W -335,512821.96N,0002751.76W -336,512829.14N,0002754.38W -340,512827.05N,0002759.97W -340L,512827.88N,0002800.28W -340R,512825.70N,0002759.53W -342,512823.44N,0002759.51W -351,512824.88N,0002734.02W -353,512826.39N,0002736.96W -355,512828.08N,0002738.81W -357,, -357L,, -357R,, -363,512813.92N,0002759.54W -364,512818.25N,0002751.42W -365,512816.79N,0002759.71W -401,512728.14N,0002653.89W -402,512727.05N,0002657.03W -403,512725.90N,0002700.26W -405,512731.71N,0002706.24W -406,512733.86N,0002703.17W -407,512735.89N,0002700.27W -408,512737.78N,0002657.57W -409,512739.66N,0002654.88W -410,512741.54N,0002652.20W -410L,, -410R,, -411,512743.42N,0002649.50W -412,512745.25N,0002646.89W -414,512744.71N,0002635.41W -415,512742.97N,0002636.62W -416,512742.79N,0002636.74W -417,512741.78N,0002637.43W -419,512740.50N,0002638.32W -420,512739.32N,0002639.12W -421,512737.59N,0002640.31W -422,512736.69N,0002636.06W -423,512738.84N,0002634.57W -424,512741.00N,0002633.08W -425,512743.20N,0002631.57W -429,512744.06N,0002625.51W -430,512741.85N,0002624.32W -431,512741.87N,0002620.43W -432,512741.73N,0002616.54W -440,512737.75N,0002704.58W -441,512739.57N,0002701.98W -449,512730.12N,0002714.35W -450,512730.34N,0002714.34W -451,512732.54N,0002714.27W -452,512732.77N,0002714.26W -453,512735.19N,0002714.18W -454,512738.95N,0002715.12W -455,512740.95N,0002717.32W -456,512742.95N,0002719.51W -461,512728.28N,0002709.74W -601,512746.11N,0002730.69W -602,512746.09N,0002734.55W -603,512746.08N,0002738.41W -604,512746.08N,0002742.27W -605,512746.06N,0002746.13W -606,512746.05N,0002749.96W -607,512745.77N,0002753.93W -608,512745.76N,0002757.72W -609,512745.74N,0002801.50W -611,512741.43N,0002819.11W -612,512739.62N,0002819.09W -613,512737.35N,0002819.08W -614,512737.64N,0002823.73W -615,512740.78N,0002823.76W -616,512743.90N,0002823.79W -457,, -457L,, -457R,, -HAP,512744.27N,0002704.97W -501,512828.22N,0002922.34W -502,512827.70N,0002920.10W -503,512827.62N,0002917.87W -505,512827.62N,0002915.29W -506,512826.36N,0002907.32W -507,512824.97N,0002907.31W -508,512823.36N,0002907.29W -509,512821.75N,0002907.28W -511,512820.36N,0002907.27W -512,512818.75N,0002907.25W -513,512816.81N,0002907.23W -514,512816.56N,0002907.23W -515,512815.12N,0002907.22W -516,512814.17N,0002907.21W -517,512813.73N,0002907.21W -518,512811.77N,0002907.19W -519,512808.36N,0002908.64W -520,512808.36N,0002911.31W -521,512808.35N,0002913.75W -522,512808.34N,0002916.34W -523,512808.33N,0002918.93W -524,512806.72N,0002919.71W -525,512806.72N,0002917.49W -526,512806.74N,0002912.56W -527,512806.75N,0002910.34W -531,512825.69N,0002858.73W -532,512823.30N,0002858.70W -533,512820.91N,000288.68W -534,512818.51N,0002858.66W -535,512816.12N,0002858.64W -536,512813.72N,0002858.62W -537,512811.33N,0002858.60W -538,512808.93N,0002858.58W -539,512806.54N,0002858.56W -541,512806.48N,0002846.00W -542,512808.88N,0002846.03W -543,512811.27N,0002846.05W -544,512813.91N,0002846.07W -544L,512813.24N,0002846.06W -544R,512814.56N,0002846.08W -545,512816.79N,0002846.10W -545L,512816.11N,0002846.09W -545R,512817.46N,0002846.10W -546,512819.67N,0002846.12W -546L,512819.00N,0002846.12W -546R,512820.37N,0002846.13W -547,512823.10N,0002846.15W -547L,512822.42N,0002846.15W -547R,512823.84N,0002846.16W -548,512826.31N,0002846.18W -548L,512825.65N,0002846.17W -548R,512827.00N,0002846.19W -551,512806.60N,0002840.53W -552,512809.00N,0002840.55W -553,512811.39N,0002840.57W -554,512813.79N,0002840.59W -555,512816.42N,0002840.61W -556,512819.92N,0002840.64W -557,512823.35N,0002840.67W -558,512826.23N,0002840.70W -561,512826.30N,0002827.53W -562,512823.42N,0002827.50W -563,512820.05N,0002827.47W -564,512817.17N,0002827.45W -565,512813.97N,0002827.42W -566,512811.33N,0002827.40W -567,512808.94N,0002827.38W -568,512806.55N,0002827.36W -572,512821.85N,0002822.01W -573,512819.46N,0002821.99W -575,512813.65N,0002821.94W -576,512809.77N,0002821.90W -581,512804.54N,0002819.07W -582,512804.56N,0002815.58W -583,512804.57N,0002811.67W -590,512809.06N,0002809.62W -591,512811.49N,0002809.65W -592,512813.91N,0002809.67W -594,512819.59N,0002809.89W -595,512822.47N,0002809.92W -596,512825.35N,0002810.03W diff --git a/src/Aircraft.php b/src/Aircraft.php new file mode 100644 index 0000000..8d6a345 --- /dev/null +++ b/src/Aircraft.php @@ -0,0 +1,70 @@ +vatsimData = $vatsimData; + } + + /** + * @param $name + * @return mixed + * @throws Exception + */ + public function __get($name) + { + if (isset($this->vatsimData[$name])) { + return $this->vatsimData[$name]; + } + + return null; + } + + /** + * @return string + */ + public function getStandIndex() + { + return $this->standIndex; + } + + /** + * @param array $stands Master list of stands + * @return Stand|null + */ + public function getStand($stands) + { + return $this->standIndex ? $stands[$this->standIndex] : null; + } + + /** + * Returns if the aircraft is on a stand or not + * + * @return bool + */ + public function onStand() + { + return !!$this->standIndex; + } + + /** + * @param string $standIndex + */ + public function setStandIndex($standIndex) + { + $this->standIndex = $standIndex; + } +} \ No newline at end of file diff --git a/src/Exceptions/CoordinateOutOfBoundsException.php b/src/Exceptions/CoordinateOutOfBoundsException.php new file mode 100644 index 0000000..dbcd678 --- /dev/null +++ b/src/Exceptions/CoordinateOutOfBoundsException.php @@ -0,0 +1,8 @@ +latitude, 0, 2); + $min = substr($this->latitude, 2, 2); + $sec = substr($this->latitude, 4, 5); + $negative = substr($this->latitude, -1) == 'S'; + return $this->convertDMSToDecimal($deg, $min, $sec, $negative); + } + + public function longitudeToDecimal() + { + $deg = substr($this->longitude, 0, 3); + $min = substr($this->longitude, 3, 2); + $sec = substr($this->longitude, 5, 5); + $negative = substr($this->longitude, -1) == 'W'; + return $this->convertDMSToDecimal($deg, $min, $sec, $negative); + } +} \ No newline at end of file diff --git a/src/Libraries/CoordinateConverter.php b/src/Libraries/CoordinateConverter.php new file mode 100644 index 0000000..fac3a22 --- /dev/null +++ b/src/Libraries/CoordinateConverter.php @@ -0,0 +1,46 @@ +latitude = $latitude; + $this->longitude = $longitude; + } + + /** + * Converts Degrees, minutes and seconds into a decimal format coordinate + * + * @param int $degrees + * @param int $minutes + * @param int $seconds + * @param bool|null $negative + * @return float|int + */ + protected function convertDMSToDecimal($degrees, $minutes, $seconds, $negative = null) + { + // Deduce sign if not given + if(!$negative){ + // Find sign from the sign of the degrees integer. If positive, assume East / North + if($degrees >= 0){ + $negative = false; + }else{ + $negative = true; + } + } + + // Converting DMS ( Degrees / minutes / seconds ) to decimal format + $float = $degrees + ((($minutes * 60) + ($seconds)) / 3600); + return $negative ? -1 * $float : $float; + } + + abstract public function latitudeToDecimal(); + abstract public function longitudeToDecimal(); +} \ No newline at end of file diff --git a/src/Stand.php b/src/Stand.php new file mode 100644 index 0000000..503c1d0 --- /dev/null +++ b/src/Stand.php @@ -0,0 +1,113 @@ +id = (string)$id; + if ($this->id == null) { + throw new InvalidStandException("An invalid stand ID/name was passed to the Stand constructor: '{$id}'"); + } + $this->latitude = $latitude; + $this->longitude = $longitude; + $this->occupier = $occupier; + } + + /** + * @param $name + * @return mixed + */ + public function __get($name) + { + if (isset($this->$name)) { + return $this->$name; + } + + return null; + } + + /** + * @param Aircraft $aircraft + */ + public function setOccupier(Aircraft $aircraft) + { + $this->occupier = $aircraft; + } + + /** + * @return bool + */ + public function isOccupied() + { + return !!$this->occupier; + } + + /** + * @return string + */ + public function getIndex() + { + return $this->getName(); + } + + /** + * @return string + */ + public function getName() + { + return $this->id; + } + + /** + * Finds and returns the stand number without an extension + * + * @param string $pattern Regex matching pattern + * @return string|null + */ + public function getRoot($pattern) + { + return preg_replace($pattern, '', $this->getName()); + } + + /** + * Finds and returns the stand's extension (if exists) + * + * @param string $pattern Regex matching pattern + * @return string|null + */ + public function getExtension($pattern) + { + $gotMatch = preg_match($pattern, $this->getName(), $matches); + if(!$gotMatch){ + return null; + } + + return $matches[0]; + } + +} \ No newline at end of file diff --git a/src/StandStatus.php b/src/StandStatus.php index a347c16..7c9e535 100644 --- a/src/StandStatus.php +++ b/src/StandStatus.php @@ -1,549 +1,532 @@ '; // Use to determine where to insert the extensions, and to represent the stand number + private $standCoordinateFormat = self::COORD_FORMAT_DECIMAL; // Stand Data file coordinate type + + + /** + * StandStatus constructor. + * @param string $standDataPath The absolute path to the stand data CSV file + * @param float $airportLatitude The decimal-format latitude of the airport + * @param float $airportLongitude The decimal-format longitude of the airport + * @param int|float $maxAirportDistance The maximum distance, in kilometers, to consider aircraft at the airport + * @param int $standCoordinateFormat The format of the coordinates in the stand data file. Defaults to decimal. + * @param bool $parseData Whether to parse the data file automatically after construction + * @throws CoordinateOutOfBoundsException + * @throws Exceptions\InvalidStandException + * @throws UnableToLoadStandDataFileException + * @throws UnableToParseStandDataException + */ + public function __construct($standDataPath, $airportLatitude, $airportLongitude, $maxAirportDistance = null, $standCoordinateFormat = self::COORD_FORMAT_DECIMAL, $parseData = true) { + $this->airportStandsFile = $standDataPath; + $this->airportLatitude = $airportLatitude; + $this->airportLongitude = $airportLongitude; + if ($standCoordinateFormat) $this->standCoordinateFormat = $standCoordinateFormat; + $this->validateCoordinatePairOrFail($airportLatitude, $airportLongitude); - public $stands = []; - public $occupiedStands = []; - public $aircraftSearchResults = []; - - /* - Airport Stand Details - */ - - public $airportICAO; - public $airportName; - public $airportCoordinates; - - public $airportStandsFile; - - /* - Configuration - */ - - private $maxStandDistance = 0.07; // In kilometeres - private $hideStandSidesWhenOccupied = true; - private $maxDistanceFromAirport = 2; // In kilometeres - private $maxAircraftAltitude = 3000; // In feet - private $maxAircraftGroundspeed = 10; // In knots - private $standExtensions = array("L", "C", "R", "A", "B"); - - - public function __construct($airportICAO, $airportStandsFile, $airportLatCoordinate, $airportLongCoordinate, $parseData = true, $maxAirportDistance = null) - { - $this->airportICAO = $airportICAO; - $this->airportStandsFile = $airportStandsFile; - $this->airportCoordinates = array("lat" => $airportLatCoordinate, "long" => $airportLongCoordinate); - if ($maxAirportDistance != null) { - $this->maxDistanceFromAirport = $maxAirportDistance; - } - - if ($this->loadStandsData()) { - if ($parseData) { - $this->parseData(); - } - } + if ($maxAirportDistance) $this->maxDistanceFromAirport = $maxAirportDistance; + // Load stand data into memory and parse if allowed + if ($this->loadStandData() && $parseData) { + $this->parseData(); } + } - public function allStands($pageNo = null, $pageLimit = null) - { - if ($pageLimit == null) { - return $this->stands; - } else { - if ($pageNo == null) { - // Assume first page - return array_slice($this->stands, 0, $pageLimit); - } else { - return array_slice($this->stands, ($pageNo * $pageLimit) - $pageLimit, $pageLimit); - } - - } - + /** + * Fetches VATSIM pilot data, and runs stand assignment algorithm + * + * @return $this + */ + public function parseData() + { + $this->occupiedStandsCache = null; + $pilots = $this->getVATSIMPilots(); + if ($pilots && $this->getAircraftWithinParameters($pilots)) { + $this->checkIfAircraftAreOnStand(); } + return $this; + } - public function occupiedStands($pageNo = null, $pageLimit = null) - { - $occupiedStands = $this->occupiedStands; - foreach ($occupiedStands as $stand) { - $occupiedStands[$stand] = $this->stands[$stand]; // Fill in pilot data - } + /* + * Useful functions + */ - if ($pageLimit == null) { - return $occupiedStands; - } else { - if ($pageNo == null) { - // Assume first page - return array_slice($occupiedStands, 0, $pageLimit); - } else { - return array_slice($occupiedStands, ($pageNo * $pageLimit) - $pageLimit, $pageLimit); - } + public function allStands() + { + return $this->stands; + } - } - } + public function occupiedStands() + { + if ($this->occupiedStandsCache) return $this->occupiedStandsCache; - public function allStandsPaginationArray($pageLimit) - { - // Work out the ammount of pages - $noOfPages = ceil(count($this->stands) / $pageLimit); - $pageinationArray = []; - for ($i = 0; $i < $noOfPages; $i++) { - $pageinationArray[] = $this->allStands($i, $pageLimit); - } + return $this->occupiedStandsCache = array_filter($this->stands, function (Stand $stand) { + return $stand->isOccupied(); + }); + } - return $pageinationArray; + public function unoccupiedStands() + { + if ($this->unoccupiedStandsCache) return $this->unoccupiedStandsCache; + return $this->unoccupiedStandsCache = array_filter($this->stands, function (Stand $stand) { + return !$stand->isOccupied(); + }); + } - } + /* + * Internal Processing Function + */ + + /** + * Loads and parse's stand data from the stand data file + * @return bool + * @throws UnableToParseStandDataException + * @throws CoordinateOutOfBoundsException|Exceptions\InvalidStandException|UnableToLoadStandDataFileException + */ + private function loadStandData() + { + $standDataStream = fopen($this->airportStandsFile, "r"); - function parseData() - { - if ($this->getAircraftWithinParameters()) { - $this->checkIfAircraftAreOnStand(); - } - return $this; + if (!$standDataStream) { + throw new UnableToLoadStandDataFileException("Unable to load the stand data file located at path '{$this->airportStandsFile}'"); } - // Load the stand data - function loadStandsData() - { - $array = $fields = []; - $i = 0; - $handle = @fopen($this->airportStandsFile, "r"); - if ($handle) { - while (($row = fgetcsv($handle, 4096)) !== false) { - if (empty($fields)) { - $fields = $row; - continue; - } - $y = 0; - foreach ($row as $k => $value) { - if ($y == 1) { // Convert LAT coordinate - $array[$row[0]][$fields[$k]] = $this->convertCAALatCoord($value); - } else if ($y == 2) { // Convert LONG coordinate - $array[$row[0]][$fields[$k]] = $this->convertCAALongCoord($value); - } else { - $array[$row[0]][$fields[$k]] = $value; - } - $y++; - } - $i++; - } - if (!feof($handle)) { - echo "Error: unexpected fgets() fail\n"; - return false; - } - fclose($handle); - } else { - return false; - } - $this->stands = $array; - return true; - } + while (($row = fgetcsv($standDataStream, 4096)) !== false) { + // Assume file data structure of id, latitude, longitude + $name = $row[0]; + $latitude = $row[1]; + $longitude = $row[2]; - function getAircraftWithinParameters() - { - $vatsim = new VatsimData(); - $vatsim->loadData(); - - try { - $pilots = $vatsim->getPilots()->toArray(); - }catch (Exception $e){ - return false; + // Check if this is a header row + if (ctype_alpha($latitude)) { + continue; } - // INSERT TEST PILOTS - //$pilots[] = array('callsign' => "TEST", "latitude" => 55.949228, "longitude" => -3.364303, "altitude" => 0, "groundspeed" => 0, "planned_destairport" => "TEST", "planned_depairport" => "TEST"); - - if (count($pilots) == 0) { - return false; + switch ($this->standCoordinateFormat) { + case self::COORD_FORMAT_CAA: + $converter = new CAACoordinateConverter($latitude, $longitude); + $latitude = $converter->latitudeToDecimal(); + $longitude = $converter->longitudeToDecimal(); + break; } - if (($this->airportCoordinates['lat'] == null) || ($this->airportCoordinates['long'] == null)) { - return false; - } - - - $filteredResults = []; - foreach ($pilots as $pilot) { - if (($this->getCoordDistance($pilot['latitude'], $pilot['longitude'], $this->airportCoordinates['lat'], $this->airportCoordinates['long']) < $this->maxDistanceFromAirport)) { - if (($pilot['groundspeed'] <= $this->maxAircraftGroundspeed) && ($pilot['altitude'] <= $this->maxAircraftAltitude)) { - $filteredResults[] = $pilot; - } - } + $this->validateCoordinatePairOrFail($latitude, $longitude); + $stand = new Stand($name, $latitude, $longitude); + if (isset($this->stands[$stand->getIndex()])) { + throw new UnableToParseStandDataException("A stand ID was defined twice in the data file! Stand ID: {$stand->getIndex()}"); } - $this->aircraftSearchResults = $filteredResults; - return true; + $this->stands[$stand->getIndex()] = $stand; } - function checkIfAircraftAreOnStand() - { - $pilots = $this->aircraftSearchResults; - $stands = $this->stands; - $standDistanceBoundary = $this->maxStandDistance; - - foreach ($pilots as $pilot) { - - // Array to hold the stands they could possibly be on - $possibleStands = []; + fclose($standDataStream); + return true; + } - // Check each stand to see how close they are - foreach ($stands as $stand) { + /** + * Returns an array of pilots from the VATSIM data feed + * + * @return array + */ + private function getVATSIMPilots() + { + $vatsimData = new VatsimData(); - // Find distance between aircraft and stand - $distance = $this->getCoordDistance($stand['latcoord'], $stand['longcoord'], $pilot['latitude'], $pilot['longitude']); + if (!$vatsimData->loadData()) { + // VATSIM data file is down. + return null; + } + return $vatsimData->getPilots()->toArray(); + } - if ($distance < $standDistanceBoundary) { - // This could be a possible stand as the aircraft is close - $possibleStands[] = array('id' => $stand['id'], 'distance' => $distance); - } + /** + * Filters network pilot data for aircraft meeting ground conditions + * + * @param array $pilots + * @return bool + */ + private function getAircraftWithinParameters(array $pilots) + { + // INSERT TEST PILOTS IF NEEDED + //$pilots[] = array('callsign' => "TEST", "latitude" => 55.949228, "longitude" => -3.364303, "altitude" => 0, "groundspeed" => 0, "planned_destairport" => "TEST", "planned_depairport" => "TEST"); - } + if (count($pilots) == 0 || (($this->airportLatitude == null) || ($this->airportLongitude == null))) { + return false; + } - // Check how many stands are possible - if (count($possibleStands) > 1) { + $filteredAircraft = []; + foreach ($pilots as $pilot) { + $aircraft = new Aircraft($pilot); - $minDistance = $standDistanceBoundary; // Cant be more than $standDistanceBoundary - $minStandID = null; + $insideAirfieldRange = $this->distanceBetweenCoordinates($aircraft->latitude, $aircraft->longitude, $this->airportLatitude, $this->airportLongitude) + < $this->maxDistanceFromAirport; + $belowSpecifiedGroundspeed = $aircraft->groundspeed <= $this->maxAircraftGroundspeed; + $belowSpecifiedAltitude = $aircraft->altitude <= $this->maxAircraftAltitude; - foreach ($possibleStands as $stand) { + if ($insideAirfieldRange && $belowSpecifiedGroundspeed && $belowSpecifiedAltitude) { + $filteredAircraft[] = $aircraft; + } - if ($stand['distance'] < $minDistance) { - // New smallest distance from stand - $minDistance = $stand['distance']; - $minStandID = $stand['id']; - } + } + $this->aircraftSearchResults = $filteredAircraft; + return true; + } - } - $this->checkAndSetStandOccupied($minStandID, $pilot); - } else if (count($possibleStands) == 1) { - $this->checkAndSetStandOccupied($possibleStands[0]['id'], $pilot); + /** + * Runs stand assignment algorithm + * + * @return void + */ + private function checkIfAircraftAreOnStand() + { + foreach ($this->aircraftSearchResults as $aircraft) { + // Best stand match + $standMatch = null; + // Check each stand to see how close they are + foreach ($this->stands as $standIndex => $stand) { + + // Find distance between aircraft and stand + $distance = $this->distanceBetweenCoordinates($stand->latitude, $stand->longitude, $aircraft->latitude, $aircraft->longitude); + + $distanceInsideBound = $distance < $this->maxStandDistance; + + if ($distanceInsideBound && (!$standMatch || $distance < $standMatch['distance'])) { + // Best match at the moment + $standMatch = [ + 'index' => $standIndex, + 'stand' => $stand, + 'distance' => $distance, + ]; } + } + // If we have a match, set it as occupied + if ($standMatch) { + $this->setStandGroupOccupied($standMatch['stand'], $aircraft); } } + } - function checkAndSetStandOccupied($standID, $pilot) - { - // Firstly set the acutal stand as occupied - $this->setStandOccupied($standID, $pilot); + /** + * Sets the given stand (by index reference) to occupied + * + * @param Stand $stand + * @param Aircraft $aircraft + */ + private function setStandOccupied(Stand $stand, Aircraft $aircraft) + { + $this->stands[$stand->getIndex()]->setOccupier($aircraft); + } - // Check for side stands - $standSides = $this->standSides($standID); - if ($standSides) { + /** + * Sets a stand and its complementing stands as occupied + * + * @param Stand $stand + * @param Aircraft $aircraft + */ + private function setStandGroupOccupied(Stand $stand, Aircraft $aircraft) + { + // Firstly set the actual stand as occupied + $this->setStandOccupied($stand, $aircraft); + $aircraft->setStandIndex($stand->getIndex()); - foreach ($standSides as $stand) { - $this->setStandOccupied($stand, $pilot); - } + // Get complementary stands + $standSides = $this->complementaryStands($stand); + if ($standSides) { - // Hide the side stands when option is set + foreach ($standSides as $stand) { if ($this->hideStandSidesWhenOccupied) { - - // Get the stand root number - $standRoot = str_replace("R", "", $standID); - $standRoot = str_replace("L", "", $standRoot); - $standRoot = str_replace("A", "", $standRoot); - $standRoot = str_replace("B", "", $standRoot); - $standRoot = str_replace("C", "", $standRoot); - - if (isset($this->stands[$standRoot])) { - // Stand root is an actual stand - if (isset($this->stands[$standRoot . "R"])) { - $this->unsetStandOccupied($standRoot . "R"); - unset($this->stands[$standRoot . "R"]); - } - if (isset($this->stands[$standRoot . "L"])) { - $this->unsetStandOccupied($standRoot . "L"); - unset($this->stands[$standRoot . "L"]); - } - if (isset($this->stands[$standRoot . "A"])) { - $this->unsetStandOccupied($standRoot . "A"); - unset($this->stands[$standRoot . "A"]); - } - if (isset($this->stands[$standRoot . "B"])) { - $this->unsetStandOccupied($standRoot . "B"); - unset($this->stands[$standRoot . "B"]); - } - - } else if (isset($this->stands[$standRoot . "C"])) { - // Stand Root + C (i.e 551C) is an actual stand - if (isset($this->stands[$standRoot . "R"])) { - $this->unsetStandOccupied($standRoot . "R"); - unset($this->stands[$standRoot . "R"]); - } - if (isset($this->stands[$standRoot . "L"])) { - $this->unsetStandOccupied($standRoot . "L"); - unset($this->stands[$standRoot . "L"]); - } - if (isset($this->stands[$standRoot . "A"])) { - $this->unsetStandOccupied($standRoot . "A"); - unset($this->stands[$standRoot . "A"]); - } - if (isset($this->stands[$standRoot . "B"])) { - $this->unsetStandOccupied($standRoot . "B"); - unset($this->stands[$standRoot . "B"]); - } - } + unset($this->stands[$stand->getIndex()]); + continue; } - } - } - - function setStandOccupied($standID, $pilot) - { - $this->stands[$standID]['occupied'] = $pilot; - $this->occupiedStands[$standID] = $standID; - } - function unsetStandOccupied($standID) - { - if (isset($this->stands[$standID]['occupied'])) { - unset($this->stands[$standID]['occupied']); + $this->setStandOccupied($stand, $aircraft); } - unset($this->occupiedStands[$standID]); } + } - function standSides($standID) - { - $standSides = []; - $stands = $this->stands; - - //Find the 'base' stand number - $standBase = str_replace("R", "", $standID); - $standBase = str_replace("L", "", $standBase); - $standBase = str_replace("A", "", $standBase); - $standBase = str_replace("B", "", $standBase); - $standBase = str_replace("C", "", $standBase); - - - // Check if stand has a side already - if (strstr($standID, "R") || strstr($standID, "L")) { - // Our stand is already L/R - if (strstr($standID, "R")) { - // Set the right hand side to occupied aswell - $newStand = str_replace("R", "L", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the base stand to occupied also - $newStand = str_replace("R", "", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the center stand to occupied also - $newStand = str_replace("R", "C", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - } else { - // Set the left hand side to occupied aswell - $newStand = str_replace("L", "R", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the base stand to occupied also - $newStand = str_replace("L", "", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the center stand to occupied also - $newStand = str_replace("L", "C", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - } - } else if (strstr($standID, "A") || strstr($standID, "B")) { - // Our stand already is A / B - - if (strstr($standID, "A")) { - // Set the right hand side to occupied aswell - $newStand = str_replace("A", "B", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the base stand to occupied also - $newStand = str_replace("A", "", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the center stand to occupied also - $newStand = str_replace("A", "C", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - } else { - // Set the right hand side to occupied aswell - $newStand = str_replace("B", "A", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the base stand to occupied also - $newStand = str_replace("B", "", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the center stand to occupied also - $newStand = str_replace("B", "C", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - } - - } else { - // Stand itself has no side, but may have L / R / A / B sides - if (isset($stands[$standBase . "L"])) { - $standSides[] = $standBase . "L"; - } - if (isset($stands[$standBase . "R"])) { - $standSides[] = $standBase . "R"; - } - if (isset($stands[$standBase . "C"])) { - $standSides[] = $standBase . "C"; - } - if (isset($stands[$standBase . "A"])) { - $standSides[] = $standBase . "A"; - } - if (isset($stands[$standBase . "B"])) { - $standSides[] = $standBase . "B"; - } - } - - if (count($standSides) == 0) { - return false; - } else { - return $standSides; - } + /** + * Generates possible matching side stands for a given stand + * + * @param Stand $stand + * @return array|null + */ + private function complementaryStands(Stand $stand) + { + $root = $stand->getRoot($this->generateExtensionRegex()); + $stands = []; + foreach (array_merge([''], $this->standExtensions) as $extension) { + // Generate expected stand name + $standName = str_replace(['', ''], [$root, $extension], $this->standExtensionPattern); + if ($standName != $stand->getName() && isset($this->stands[$standName])) $stands[] = $this->stands[$standName]; } + return count($stands) > 0 ? $stands : null; + } - /* + /** + * Generates the regex to capture a stand's extension + * + * @return string + */ + private function generateExtensionRegex() + { + // Compose regex + $extensions = "(" . implode('|', $this->standExtensions) . ")"; + $pattern = '/^' . $this->standExtensionPattern . '$/'; + return str_replace(['', ''], ['[0-9]+', $extensions], $pattern); + } - Support Functions - */ + /* + * Helpers + */ - function getCoordDistance($latitude1, $longitude1, $latitude2, $longitude2) - { - $earth_radius = 6371; - $latitude1 = floatval($latitude1); - $longitude1 = floatval($longitude1); - $latitude2 = floatval($latitude2); - $longitude2 = floatval($longitude2); + /** + * Validates a given latitude/longitude pair and throws an exception if invalid + * + * @param $latitude + * @param $longitude + * @return bool + * @throws CoordinateOutOfBoundsException + */ + private function validateCoordinatePairOrFail($latitude, $longitude) + { + if ($this->validateLatitudeCoordinate($latitude) && $this->validateLongitudeCoordinate($longitude)) { + return true; + } + throw new CoordinateOutOfBoundsException; + } - $dLat = deg2rad($latitude2 - $latitude1); - $dLon = deg2rad($longitude2 - $longitude1); + /** + * Validates a given latitude coordinate to make sure it is realistic + * + * @param float $coordinate + * @return bool + */ + private function validateLatitudeCoordinate($coordinate) + { + return $coordinate <= 90 && $coordinate >= -90; + } - $a = sin($dLat / 2) * sin($dLat / 2) + cos(deg2rad($latitude1)) * cos(deg2rad($latitude2)) * sin($dLon / 2) * sin($dLon / 2); - $c = 2 * asin(sqrt($a)); - $d = $earth_radius * $c; + /** + * Validates a given longitude coordinate to make sure it is realistic + * + * @param float $coordinate + * @return bool + */ + private function validateLongitudeCoordinate($coordinate) + { + return $coordinate <= 180 && $coordinate >= -180; + } - return $d; + /** + * Return the distance in kilometres between two sets of coordinates + * @param float $latitude1 Latitude in decimal format + * @param float $longitude1 Longitude in decimal format + * @param float $latitude2 Latitude in decimal format + * @param float $longitude2 Longitude in decimal format + * @return float|int + */ + private function distanceBetweenCoordinates($latitude1, $longitude1, $latitude2, $longitude2) + { + $earth_radius = 6371; - } + $latitude1 = floatval($latitude1); + $longitude1 = floatval($longitude1); + $latitude2 = floatval($latitude2); + $longitude2 = floatval($longitude2); - function convertCoordinateToDecimal($deg, $min, $sec, $dir) - { - // Converting DMS ( Degrees / minutes / seconds ) to decimal format - if ($dir == "W") { - return "-" . ($deg + ((($min * 60) + ($sec)) / 3600)); - } else if ($dir == "S") { - return "-" . ($deg + ((($min * 60) + ($sec)) / 3600)); - } - return $deg + ((($min * 60) + ($sec)) / 3600); - } + $dLat = deg2rad($latitude2 - $latitude1); + $dLon = deg2rad($longitude2 - $longitude1); - function convertCAALatCoord($coord) - { - $deg = substr($coord, 0, 2); - $min = substr($coord, 2, 2); - $sec = substr($coord, 4, 5); - $dir = substr($coord, -1); - return $this->convertCoordinateToDecimal($deg, $min, $sec, $dir); - } + $a = sin($dLat / 2) * sin($dLat / 2) + cos(deg2rad($latitude1)) * cos(deg2rad($latitude2)) * sin($dLon / 2) * sin($dLon / 2); + $c = 2 * asin(sqrt($a)); + return $earth_radius * $c; + } - function convertCAALongCoord($coord) - { - $deg = substr($coord, 0, 3); - $min = substr($coord, 3, 2); - $sec = substr($coord, 5, 5); - $dir = substr($coord, -1); - return $this->convertCoordinateToDecimal($deg, $min, $sec, $dir); - } - function getMaxStandDistance() - { - return $this->maxStandDistance; - } + /* + * Getters and Setters + */ - function setMaxStandDistance($distance) - { - $this->maxStandDistance = $distance; - return $this; - } + /** + * @return float + */ + public function getMaxStandDistance() + { + return $this->maxStandDistance; + } - function getHideStandSidesWhenOccupied() - { - return $this->hideStandSidesWhenOccupied; - } + /** + * @param float|int $distance + * @return $this + */ + public function setMaxStandDistance($distance) + { + $this->maxStandDistance = $distance; + return $this; + } - function setHideStandSidesWhenOccupied($bool) - { - $this->hideStandSidesWhenOccupied = $bool; - return $this; - } + /** + * @return bool + */ + public function getHideStandSidesWhenOccupied() + { + return $this->hideStandSidesWhenOccupied; + } - function getMaxDistanceFromAirport() - { - return $this->maxDistanceFromAirport; - } + /** + * @param bool $bool + * @return $this + */ + public function setHideStandSidesWhenOccupied($bool) + { + $this->hideStandSidesWhenOccupied = $bool; + return $this; + } - function setMaxDistanceFromAirport($distance) - { - $this->maxDistanceFromAirport = $distance; - return $this; - } + /** + * @return float|int + */ + public function getMaxDistanceFromAirport() + { + return $this->maxDistanceFromAirport; + } - function getMaxAircraftAltitude() - { - return $this->maxAircraftAltitude; - } + /** + * @param float|int $distance + * @return $this + */ + public function setMaxDistanceFromAirport($distance) + { + $this->maxDistanceFromAirport = $distance; + return $this; + } - function setMaxAircraftAltitude($altitude) - { - $this->maxAircraftAltitude = $altitude; - return $this; - } + /** + * @return int + */ + public function getMaxAircraftAltitude() + { + return $this->maxAircraftAltitude; + } - function getMaxAircraftGroundspeed() - { - return $this->maxAircraftGroundspeed; - } + /** + * @param int $altitude + * @return $this + */ + public function setMaxAircraftAltitude($altitude) + { + $this->maxAircraftAltitude = $altitude; + return $this; + } - function setMaxAircraftGroundspeed($speed) - { - $this->maxAircraftGroundspeed = $speed; - return $this; - } + /** + * @return int + */ + public function getMaxAircraftGroundspeed() + { + return $this->maxAircraftGroundspeed; + } - function getStandExtensions() - { - return $this->standExtensions; - } + /** + * @param int $speed + * @return $this + */ + public function setMaxAircraftGroundspeed($speed) + { + $this->maxAircraftGroundspeed = $speed; + return $this; + } - function setStandExtensions($standArray) - { - $this->standExtensions = $standArray; - return $this; - } + /** + * @return string[] + */ + public function getStandExtensions() + { + return $this->standExtensions; + } + /** + * @param string[] $standArray + * @return $this + */ + public function setStandExtensions($standArray) + { + $this->standExtensions = $standArray; + return $this; + } + /** + * @return Aircraft[] + */ + public function getAllAircraft() + { + return $this->aircraftSearchResults; } +} -?> From 663b1361f1671f9006cd2607620ce0806e8079f7 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 00:15:35 +0100 Subject: [PATCH 03/25] Further testing --- composer.json | 9 +- examples/egkkStands.php | 2 +- src/Libraries/CoordinateConverter.php | 11 +- src/Stand.php | 90 ++++++++++--- src/StandStatus.php | 43 +++---- tests/Fixtures/SampleData/decimalexample.csv | 3 + .../Fixtures/SampleData}/egkkstands.csv | 0 tests/Fixtures/SampleData/invalidexample.csv | 4 + tests/TestCase.php | 20 +++ tests/Unit/AircraftTest.php | 39 ++++++ .../Libraries/CoordinateConverterTest.php | 24 ++++ tests/Unit/StandStatusTest.php | 119 ++++++++++++++++++ tests/Unit/StandTest.php | 91 ++++++++++++++ 13 files changed, 405 insertions(+), 50 deletions(-) create mode 100644 tests/Fixtures/SampleData/decimalexample.csv rename {examples/standData => tests/Fixtures/SampleData}/egkkstands.csv (100%) create mode 100644 tests/Fixtures/SampleData/invalidexample.csv create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/AircraftTest.php create mode 100644 tests/Unit/Libraries/CoordinateConverterTest.php create mode 100644 tests/Unit/StandStatusTest.php create mode 100644 tests/Unit/StandTest.php diff --git a/composer.json b/composer.json index 1f29f0d..b41d01c 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ } ], "require": { + "php": ">=7.2", "skymeyer/vatsimphp": "^2.0" }, "autoload": { @@ -20,7 +21,13 @@ "CobaltGrid\\VatsimStandStatus\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, "require-dev": { - "phpunit/phpunit": "^9.1" + "phpunit/phpunit": "^9.1", + "mockery/mockery": "^1.3" } } diff --git a/examples/egkkStands.php b/examples/egkkStands.php index 1a2a8ef..d9befff 100644 --- a/examples/egkkStands.php +++ b/examples/egkkStands.php @@ -4,7 +4,7 @@ require_once '../vendor/autoload.php'; -$StandStatus = new StandStatus(dirname(__FILE__) . "/standData/egkkstands.csv", 51.148056, -0.190278, null, StandStatus::COORD_FORMAT_CAA); +$StandStatus = new StandStatus(dirname(__FILE__) . "../tests/Fixtures/SampleData/egkkstands.csv", 51.148056, -0.190278, null, StandStatus::COORD_FORMAT_CAA); ?> diff --git a/src/Libraries/CoordinateConverter.php b/src/Libraries/CoordinateConverter.php index fac3a22..e779fb5 100644 --- a/src/Libraries/CoordinateConverter.php +++ b/src/Libraries/CoordinateConverter.php @@ -18,14 +18,19 @@ public function __construct($latitude = null, $longitude = null) /** * Converts Degrees, minutes and seconds into a decimal format coordinate * - * @param int $degrees - * @param int $minutes - * @param int $seconds + * @param float $degrees + * @param float $minutes + * @param float $seconds * @param bool|null $negative * @return float|int */ protected function convertDMSToDecimal($degrees, $minutes, $seconds, $negative = null) { + // Casts + $degrees = floatval($degrees); + $minutes = floatval($minutes); + $seconds = floatval($seconds); + // Deduce sign if not given if(!$negative){ // Find sign from the sign of the degrees integer. If positive, assume East / North diff --git a/src/Stand.php b/src/Stand.php index 503c1d0..7ac0742 100644 --- a/src/Stand.php +++ b/src/Stand.php @@ -19,15 +19,21 @@ class Stand /* The Stand Occupier. Instance of Aircraft */ private $occupier; + private $standExtensions; + private $standPattern; + /** * Stand constructor. - * @param string|int $id - * @param float $latitude - * @param float $longitude - * @param Aircraft|null $occupier + * + * @param string|int $id Stand Identifier. e.g. 25L + * @param float $latitude Stand Latitude + * @param float $longitude Stand Longitude + * @param array $standExtensions Array of stand extension. e.g. ['L', 'B'] + * @param string $standPattern The stand pattern + * @param Aircraft|null $occupier Optional stand occupier * @throws InvalidStandException */ - public function __construct($id, $latitude, $longitude, Aircraft $occupier = null) + public function __construct($id, $latitude, $longitude, $standExtensions, $standPattern, Aircraft $occupier = null) { $this->id = (string)$id; if ($this->id == null) { @@ -36,6 +42,8 @@ public function __construct($id, $latitude, $longitude, Aircraft $occupier = nul $this->latitude = $latitude; $this->longitude = $longitude; $this->occupier = $occupier; + $this->standExtensions = $standExtensions; + $this->standPattern = $standPattern; } /** @@ -54,7 +62,7 @@ public function __get($name) /** * @param Aircraft $aircraft */ - public function setOccupier(Aircraft $aircraft) + public function setOccupier(?Aircraft $aircraft) { $this->occupier = $aircraft; } @@ -70,9 +78,9 @@ public function isOccupied() /** * @return string */ - public function getIndex() + public function getKey() { - return $this->getName(); + return $this->id; } /** @@ -80,34 +88,82 @@ public function getIndex() */ public function getName() { - return $this->id; + return $this->getKey(); } /** * Finds and returns the stand number without an extension * - * @param string $pattern Regex matching pattern * @return string|null */ - public function getRoot($pattern) + public function getRoot() { - return preg_replace($pattern, '', $this->getName()); + if(!$matches = $this->matchNameAgainstRegex()){ + return null; + } + + if(count($matches) < 3){ + // No extension + return $matches[1]; + } + + return $this->standRootComesFirst() ? $matches[1] : $matches[2]; } /** * Finds and returns the stand's extension (if exists) * - * @param string $pattern Regex matching pattern * @return string|null */ - public function getExtension($pattern) + public function getExtension() { - $gotMatch = preg_match($pattern, $this->getName(), $matches); - if(!$gotMatch){ + if(!$matches = $this->matchNameAgainstRegex()){ return null; } - return $matches[0]; + if(count($matches) < 3){ + // No extension + return null; + } + + return !$this->standRootComesFirst() ? $matches[1] : $matches[2]; + } + + /** + * Matches the name against the extension regex + * + * @return array|null + */ + private function matchNameAgainstRegex() + { + $gotMatch = preg_match($this->generateExtensionRegex(), $this->getName(), $matches); + if (!$gotMatch) { + return null; + } + return $matches; + } + + /** + * Generates the regex to capture a stand's extension + * + * @return string + */ + private function generateExtensionRegex() + { + // Compose regex + $extensions = "(" . implode('|', $this->standExtensions) . ")?"; + $pattern = '/^' . $this->standPattern . '$/'; + return str_replace(['', ''], ['([0-9]+)', $extensions], $pattern); + } + + /** + * Returns whether in the stand pattern, the root or extension comes first + * + * @return bool; + */ + private function standRootComesFirst() + { + return strpos($this->standPattern, '') < strpos($this->standPattern, ''); } } \ No newline at end of file diff --git a/src/StandStatus.php b/src/StandStatus.php index 7c9e535..14b3856 100644 --- a/src/StandStatus.php +++ b/src/StandStatus.php @@ -31,7 +31,9 @@ class StandStatus Supported Coordinate Formats */ + const COORD_FORMAT_DECIMAL = 1; + // Coordinates of format 521756.91N const COORD_FORMAT_CAA = 2; /* @@ -56,8 +58,8 @@ class StandStatus private $maxAircraftAltitude = 3000; // In feet private $maxAircraftGroundspeed = 10; // In knots - private $standExtensions = ["L", "C", "R", "A", "B"]; // Possible stand extensions/combinations. E.G Stand 25 includes 25L and 25R - private $standExtensionPattern = ''; // Use to determine where to insert the extensions, and to represent the stand number + private $standExtensions = ["L", "C", "R", "A", "B", "N", "E", "S", "W"]; // Possible stand extensions/combinations. E.G Stand 25 includes 25L and 25R + private $standExtensionPattern = ''; // Use to determine where to insert the extensions, and to represent the stand number. Can only use one of each private $standCoordinateFormat = self::COORD_FORMAT_DECIMAL; // Stand Data file coordinate type @@ -144,7 +146,7 @@ public function unoccupiedStands() */ private function loadStandData() { - $standDataStream = fopen($this->airportStandsFile, "r"); + $standDataStream = @fopen($this->airportStandsFile, "r"); if (!$standDataStream) { throw new UnableToLoadStandDataFileException("Unable to load the stand data file located at path '{$this->airportStandsFile}'"); @@ -170,11 +172,12 @@ private function loadStandData() } $this->validateCoordinatePairOrFail($latitude, $longitude); - $stand = new Stand($name, $latitude, $longitude); - if (isset($this->stands[$stand->getIndex()])) { - throw new UnableToParseStandDataException("A stand ID was defined twice in the data file! Stand ID: {$stand->getIndex()}"); + $stand = new Stand($name, $latitude, $longitude, $this->standExtensions, $this->standExtensionPattern); + + if (isset($this->stands[$stand->getKey()])) { + throw new UnableToParseStandDataException("A stand ID was defined twice in the data file! Stand ID: {$stand->getKey()}"); } - $this->stands[$stand->getIndex()] = $stand; + $this->stands[$stand->getKey()] = $stand; } fclose($standDataStream); @@ -186,7 +189,7 @@ private function loadStandData() * * @return array */ - private function getVATSIMPilots() + public function getVATSIMPilots() { $vatsimData = new VatsimData(); @@ -206,9 +209,6 @@ private function getVATSIMPilots() */ private function getAircraftWithinParameters(array $pilots) { - // INSERT TEST PILOTS IF NEEDED - //$pilots[] = array('callsign' => "TEST", "latitude" => 55.949228, "longitude" => -3.364303, "altitude" => 0, "groundspeed" => 0, "planned_destairport" => "TEST", "planned_depairport" => "TEST"); - if (count($pilots) == 0 || (($this->airportLatitude == null) || ($this->airportLongitude == null))) { return false; } @@ -275,7 +275,7 @@ private function checkIfAircraftAreOnStand() */ private function setStandOccupied(Stand $stand, Aircraft $aircraft) { - $this->stands[$stand->getIndex()]->setOccupier($aircraft); + $this->stands[$stand->getKey()]->setOccupier($aircraft); } /** @@ -288,7 +288,7 @@ private function setStandGroupOccupied(Stand $stand, Aircraft $aircraft) { // Firstly set the actual stand as occupied $this->setStandOccupied($stand, $aircraft); - $aircraft->setStandIndex($stand->getIndex()); + $aircraft->setStandIndex($stand->getKey()); // Get complementary stands $standSides = $this->complementaryStands($stand); @@ -296,7 +296,7 @@ private function setStandGroupOccupied(Stand $stand, Aircraft $aircraft) foreach ($standSides as $stand) { if ($this->hideStandSidesWhenOccupied) { - unset($this->stands[$stand->getIndex()]); + unset($this->stands[$stand->getKey()]); continue; } @@ -313,7 +313,7 @@ private function setStandGroupOccupied(Stand $stand, Aircraft $aircraft) */ private function complementaryStands(Stand $stand) { - $root = $stand->getRoot($this->generateExtensionRegex()); + $root = $stand->getRoot(); $stands = []; foreach (array_merge([''], $this->standExtensions) as $extension) { @@ -325,19 +325,6 @@ private function complementaryStands(Stand $stand) return count($stands) > 0 ? $stands : null; } - /** - * Generates the regex to capture a stand's extension - * - * @return string - */ - private function generateExtensionRegex() - { - // Compose regex - $extensions = "(" . implode('|', $this->standExtensions) . ")"; - $pattern = '/^' . $this->standExtensionPattern . '$/'; - return str_replace(['', ''], ['[0-9]+', $extensions], $pattern); - } - /* * Helpers diff --git a/tests/Fixtures/SampleData/decimalexample.csv b/tests/Fixtures/SampleData/decimalexample.csv new file mode 100644 index 0000000..afbc98e --- /dev/null +++ b/tests/Fixtures/SampleData/decimalexample.csv @@ -0,0 +1,3 @@ +id,latcoord,longcoord +1,51.20,-0.5678 +2,48.0,0.123 diff --git a/examples/standData/egkkstands.csv b/tests/Fixtures/SampleData/egkkstands.csv similarity index 100% rename from examples/standData/egkkstands.csv rename to tests/Fixtures/SampleData/egkkstands.csv diff --git a/tests/Fixtures/SampleData/invalidexample.csv b/tests/Fixtures/SampleData/invalidexample.csv new file mode 100644 index 0000000..fb91c71 --- /dev/null +++ b/tests/Fixtures/SampleData/invalidexample.csv @@ -0,0 +1,4 @@ +id,latcoord,longcoord +1,51.20,-0.5678 +2,48.0,0.123 +2,48.0,0.123 diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..f0eaf95 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,20 @@ + "TEST", + "latitude" => 55.949228, + "longitude" => -3.364303, + "altitude" => 0, + "groundspeed" => 0, + "planned_destairport" => "TEST", + "planned_depairport" => "TEST" + ]; +} \ No newline at end of file diff --git a/tests/Unit/AircraftTest.php b/tests/Unit/AircraftTest.php new file mode 100644 index 0000000..f926628 --- /dev/null +++ b/tests/Unit/AircraftTest.php @@ -0,0 +1,39 @@ +instance = new Aircraft($this->testPilot); + } + + public function testItMapsClassPropertiesToData() + { + $this->assertEquals($this->testPilot['callsign'], $this->instance->callsign); + $this->assertEquals($this->testPilot['latitude'], $this->instance->latitude); + $this->assertNull($this->instance->unknown_index); + } + + public function testItCanGetAndSetStandIndex() + { + $this->assertNull($this->instance->getStandIndex()); + $this->instance->setStandIndex('21R'); + $this->assertEquals('21R', $this->instance->getStandIndex()); + } + + public function testItCorrectlyReportsIfOnStand() + { + $this->assertFalse($this->instance->onStand()); + $this->instance->setStandIndex('21R'); + $this->assertTrue($this->instance->onStand()); + } +} \ No newline at end of file diff --git a/tests/Unit/Libraries/CoordinateConverterTest.php b/tests/Unit/Libraries/CoordinateConverterTest.php new file mode 100644 index 0000000..7ce9009 --- /dev/null +++ b/tests/Unit/Libraries/CoordinateConverterTest.php @@ -0,0 +1,24 @@ +assertEquals(51.154819444444444, $converter->latitudeToDecimal()); + $this->assertEquals(-0.1648138888888889, $converter->longitudeToDecimal()); + $this->assertEquals(0.1648138888888889, $converter2->longitudeToDecimal()); + } +} \ No newline at end of file diff --git a/tests/Unit/StandStatusTest.php b/tests/Unit/StandStatusTest.php new file mode 100644 index 0000000..886bc9c --- /dev/null +++ b/tests/Unit/StandStatusTest.php @@ -0,0 +1,119 @@ + "TEST1", + "latitude" => 51.148056, + "longitude" => -0.190278, + "altitude" => 0, + "groundspeed" => 0 + ], + [ // Not on stand + 'callsign' => "TEST2", + "latitude" => 51.15092, + "longitude" => -0.18304, + "altitude" => 0, + "groundspeed" => 5 + ], + [ // Doesn't match altitude filter + 'callsign' => "TEST3", + "latitude" => 51.15092, + "longitude" => -0.18304, + "altitude" => 5000, + "groundspeed" => 5, + ], + [ // Doesn't match distance filter + 'callsign' => "TEST4", + "latitude" => 53.15092, + "longitude" => -0.18304, + "altitude" => 0, + "groundspeed" => 0, + ], + [ // On Stand 43N + 'callsign' => "TEST5", + "latitude" => 51.15712, + "longitude" => -0.17373, + "altitude" => 0, + "groundspeed" => 0, + ] + + ]; + + private $instance; + + protected function setUp(): void + { + parent::setUp(); + $this->standDataFileCAA = dirname(__DIR__)."/Fixtures/SampleData/egkkstands.csv"; + $this->standDataFileDecimal = dirname(__DIR__)."/Fixtures/SampleData/decimalexample.csv"; + $this->standDataFileInvalid = dirname(__DIR__)."/Fixtures/SampleData/invalidexample.csv"; + + $this->instance = \Mockery::mock(StandStatus::class, [ + $this->standDataFileCAA, + 51.148056, + -0.190278, + null, + StandStatus::COORD_FORMAT_CAA, + false + ])->makePartial(); + $this->instance->shouldReceive('getVATSIMPilots')->andReturn($this->testPilots); + } + + public function testItThrowsWithInvalidCoordinates() + { + $this->expectException(CoordinateOutOfBoundsException::class); + new StandStatus($this->standDataFileCAA, 1000, 0.1); + } + + public function testItThrowsIfItCantFindStandDataFile() + { + $this->expectException(UnableToLoadStandDataFileException::class); + new StandStatus('', 0.1, 0.1); + } + + public function testItThrowsIfItDataFileContentInvalid() + { + $this->expectException(UnableToParseStandDataException::class); + new StandStatus($this->standDataFileInvalid, 0.1, 0.1); + } + + public function testItParsesOk() + { + $this->instance->setHideStandSidesWhenOccupied(false)->parseData(); + $this->assertCount(186, $this->instance->allStands()); + } + + public function testItFiltersPilotsCorrectly() + { + $this->instance->parseData(); + $this->assertEquals(['TEST1', 'TEST2', 'TEST5'], array_map(function(Aircraft $aircraft){ + return $aircraft->callsign; + }, $this->instance->getAllAircraft())); + } + + public function testItAssignsStandsCorrectly() + { + $this->instance->parseData(); + $this->assertNull($this->instance->getAllAircraft()[0]->getStandIndex()); + $this->assertNull($this->instance->getAllAircraft()[1]->getStandIndex()); + $this->assertEquals('43N', $this->instance->getAllAircraft()[2]->getStandIndex()); + } +} \ No newline at end of file diff --git a/tests/Unit/StandTest.php b/tests/Unit/StandTest.php new file mode 100644 index 0000000..7c30f8d --- /dev/null +++ b/tests/Unit/StandTest.php @@ -0,0 +1,91 @@ +'; + + protected function setUp(): void + { + parent::setUp(); + $this->instance = new Stand($this->standID, $this->standLatitude, $this->standLongitude, $this->standExtensions, $this->standPattern); + } + + public function testItThrowsIfNullID() + { + $this->expectException(InvalidStandException::class); + new Stand(null,1,2, $this->standExtensions, $this->standPattern); + } + + public function testItThrowsIfEmptyID() + { + $this->expectException(InvalidStandException::class); + new Stand('',1,2, $this->standExtensions, $this->standPattern); + } + + public function testItMapsClassPropertiesToData() + { + $this->assertEquals($this->standID, $this->instance->id); + $this->assertEquals($this->standLatitude, $this->instance->latitude); + $this->assertEquals($this->standLongitude, $this->instance->longitude); + $this->assertNull($this->instance->occupier); + $this->assertNull($this->instance->invalid_property); + } + + public function testItCanSetOccupier() + { + $this->assertFalse($this->instance->isOccupied()); + $this->instance->setOccupier(new Aircraft($this->testPilot)); + $this->assertTrue($this->instance->isOccupied()); + $this->instance->setOccupier(null); + $this->assertFalse($this->instance->isOccupied()); + } + + public function testOccupierMustBeAircraftOrNull() + { + $this->expectException(\TypeError::class); + $this->instance->setOccupier('Not an aircraft'); + } + + public function testItCanGetStandKey() + { + $this->assertEquals($this->standID, $this->instance->getKey()); + } + + public function testItCanGetStandName() + { + $this->assertEquals($this->standID, $this->instance->getName()); + } + + public function testItCanDetermineRoot() + { + $stand2 = new Stand('144', $this->standLatitude, $this->standLongitude, $this->standExtensions, $this->standPattern); + $stand3 = new Stand('L73', $this->standLatitude, $this->standLongitude, $this->standExtensions, ''); + + $this->assertEquals('25', $this->instance->getRoot()); + $this->assertEquals('144', $stand2->getRoot()); + $this->assertEquals('73', $stand3->getRoot()); + } + + public function testItCanDetermineExtension() + { + $stand2 = new Stand('144', $this->standLatitude, $this->standLongitude, $this->standExtensions, $this->standPattern); + + $this->assertEquals('R', $this->instance->getExtension()); + $this->assertNull($stand2->getExtension()); + } + +} \ No newline at end of file From 98eafdb60c9d18bbf3cd69ce0f5e9c14faa7d9d1 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 00:20:20 +0100 Subject: [PATCH 04/25] Create ci-workflow.yml --- .github/workflows/ci-workflow.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/ci-workflow.yml diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml new file mode 100644 index 0000000..0fa7429 --- /dev/null +++ b/.github/workflows/ci-workflow.yml @@ -0,0 +1,24 @@ +name: Stand Status CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Validate composer.json and composer.lock + run: composer validate + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Run PHPUNIT + run: vendor/bin/phpunit From 2e057387f30dd24a7761c8f6bb990f3e7a2a3088 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 00:22:12 +0100 Subject: [PATCH 05/25] Update ci-workflow.yml --- .github/workflows/ci-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index 0fa7429..ac11a0b 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -2,7 +2,7 @@ name: Stand Status CI on: push: - branches: [ master ] + branches: [ * ] pull_request: branches: [ master ] From 5ecacdfec98ec7304705b75a8be377501c66269a Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 00:22:53 +0100 Subject: [PATCH 06/25] Update ci-workflow.yml --- .github/workflows/ci-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index ac11a0b..0fa7429 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -2,7 +2,7 @@ name: Stand Status CI on: push: - branches: [ * ] + branches: [ master ] pull_request: branches: [ master ] From 8d9bdd6fa1bc4ef7897c3eb6358fa6b4a7ca07d3 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 00:32:39 +0100 Subject: [PATCH 07/25] Update ci-workflow.yml --- .github/workflows/ci-workflow.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index 0fa7429..3c708f3 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -8,11 +8,24 @@ on: jobs: build: - + name: CI on PHP Version ${{ matrix.php_versions }} runs-on: ubuntu-latest + + strategy: + matrix: + php_versions: ['7.2', '7.3', '7.4'] steps: - - uses: actions/checkout@v2 + - name: Checkout Repo + uses: actions/checkout@v2 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + + - name: Verify PHP Version + run: php -v - name: Validate composer.json and composer.lock run: composer validate @@ -21,4 +34,4 @@ jobs: run: composer install --prefer-dist --no-progress --no-suggest - name: Run PHPUNIT - run: vendor/bin/phpunit + run: vendor/bin/phpunit --coverage-text From d541940af591569802d88b6209626536176a9e56 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 00:33:36 +0100 Subject: [PATCH 08/25] Update ci-workflow.yml --- .github/workflows/ci-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index 3c708f3..61dc072 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -22,7 +22,7 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php-versions }} + php-version: ${{ matrix.php_versions }} - name: Verify PHP Version run: php -v From 76ca2b9211d335092169f0aed39775a75020d9a3 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 00:41:43 +0100 Subject: [PATCH 09/25] Update ci-workflow.yml --- .github/workflows/ci-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index 61dc072..5b86032 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -34,4 +34,4 @@ jobs: run: composer install --prefer-dist --no-progress --no-suggest - name: Run PHPUNIT - run: vendor/bin/phpunit --coverage-text + run: vendor/bin/phpunit --coverage-text --coverage-clover From 22a47b21003b110d975f50a58ad2f9b498727495 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 00:41:59 +0100 Subject: [PATCH 10/25] Update phpunit.xml --- phpunit.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit.xml b/phpunit.xml index 66bad86..e2d82e7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,7 +3,7 @@ xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.1/phpunit.xsd" bootstrap="vendor/autoload.php" executionOrder="depends,defects" - forceCoversAnnotation="true" + colors="true" beStrictAboutCoversAnnotation="true" beStrictAboutOutputDuringTests="true" beStrictAboutTodoAnnotatedTests="true" From 10773e1a2393fe265c6396e3dd48bf22137a4108 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 00:42:54 +0100 Subject: [PATCH 11/25] Update ci-workflow.yml --- .github/workflows/ci-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index 5b86032..61dc072 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -34,4 +34,4 @@ jobs: run: composer install --prefer-dist --no-progress --no-suggest - name: Run PHPUNIT - run: vendor/bin/phpunit --coverage-text --coverage-clover + run: vendor/bin/phpunit --coverage-text From 942951ae5c36426251400542159fae4d99454604 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 00:46:13 +0100 Subject: [PATCH 12/25] Downgrade phpunit for PHP 7.2 compatability --- .gitignore | 2 ++ composer.json | 59 ++++++++++++++++++++++++++++----------------------- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index fa36fe5..67b6870 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ vendor composer.lock .idea + +.phpunit.result.cache diff --git a/composer.json b/composer.json index b41d01c..a41e8a1 100644 --- a/composer.json +++ b/composer.json @@ -1,33 +1,38 @@ { - "name": "cobaltgrid/vatsim-stand-status", - "description" : "A PHP library to access the status of aircraft stands on VATSIM", - "keywords" : ["vatsim", "stand", "stands", "flight simulator"], - "homepage" : "https://github.com/atoff/vatsim-stand-status", - "type" : "library", - "license" : "AGPL-3.0-or-later", - "authors" : [ - { - "name" : "Alex Toff", - "email" : "alex.toff@cobaltgrid.com", - "homepage" : "https://cobaltgrid.com" - } - ], - "require": { - "php": ">=7.2", - "skymeyer/vatsimphp": "^2.0" - }, - "autoload": { - "psr-4": { - "CobaltGrid\\VatsimStandStatus\\": "src/" - } - }, - "autoload-dev": { + "name": "cobaltgrid/vatsim-stand-status", + "description": "A PHP library to access the status of aircraft stands on VATSIM", + "keywords": [ + "vatsim", + "stand", + "stands", + "flight simulator" + ], + "homepage": "https://github.com/atoff/vatsim-stand-status", + "type": "library", + "license": "AGPL-3.0-or-later", + "authors": [ + { + "name": "Alex Toff", + "email": "alex.toff@cobaltgrid.com", + "homepage": "https://cobaltgrid.com" + } + ], + "require": { + "php": ">=7.2", + "skymeyer/vatsimphp": "^2.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5", + "mockery/mockery": "^1.3" + }, + "autoload": { "psr-4": { - "Tests\\": "tests/" + "CobaltGrid\\VatsimStandStatus\\": "src/" } }, - "require-dev": { - "phpunit/phpunit": "^9.1", - "mockery/mockery": "^1.3" + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" } + } } From 99cba6297351cd3b251c7ac5d000b40c2430c91d Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 14:35:14 +0100 Subject: [PATCH 13/25] Improve test coverage to 100% --- .gitignore | 2 +- README.md | 2 + src/Libraries/CoordinateConverter.php | 4 +- src/StandStatus.php | 66 ++++++++---- tests/Unit/AircraftTest.php | 8 ++ .../Libraries/CoordinateConverterTest.php | 19 ++++ tests/Unit/StandStatusTest.php | 102 ++++++++++++++++++ tests/Unit/StandTest.php | 8 ++ 8 files changed, 185 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 67b6870..9d76383 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ vendor composer.lock .idea - +build .phpunit.result.cache diff --git a/README.md b/README.md index fc1460f..32ab802 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # vatsim-stand-status +![Stand Status CI](https://github.com/atoff/vatsim-stand-status/workflows/Stand%20Status%20CI/badge.svg) + ## About #### Description diff --git a/src/Libraries/CoordinateConverter.php b/src/Libraries/CoordinateConverter.php index e779fb5..57c2e55 100644 --- a/src/Libraries/CoordinateConverter.php +++ b/src/Libraries/CoordinateConverter.php @@ -32,7 +32,7 @@ protected function convertDMSToDecimal($degrees, $minutes, $seconds, $negative = $seconds = floatval($seconds); // Deduce sign if not given - if(!$negative){ + if($negative == null){ // Find sign from the sign of the degrees integer. If positive, assume East / North if($degrees >= 0){ $negative = false; @@ -42,7 +42,7 @@ protected function convertDMSToDecimal($degrees, $minutes, $seconds, $negative = } // Converting DMS ( Degrees / minutes / seconds ) to decimal format - $float = $degrees + ((($minutes * 60) + ($seconds)) / 3600); + $float = abs($degrees) + ((($minutes * 60) + ($seconds)) / 3600); return $negative ? -1 * $float : $float; } diff --git a/src/StandStatus.php b/src/StandStatus.php index 14b3856..232b4c1 100644 --- a/src/StandStatus.php +++ b/src/StandStatus.php @@ -76,7 +76,13 @@ class StandStatus * @throws UnableToLoadStandDataFileException * @throws UnableToParseStandDataException */ - public function __construct($standDataPath, $airportLatitude, $airportLongitude, $maxAirportDistance = null, $standCoordinateFormat = self::COORD_FORMAT_DECIMAL, $parseData = true) + public function __construct( + $standDataPath, + $airportLatitude, + $airportLongitude, + $maxAirportDistance = null, + $standCoordinateFormat = self::COORD_FORMAT_DECIMAL, + $parseData = true) { $this->airportStandsFile = $standDataPath; $this->airportLatitude = $airportLatitude; @@ -87,9 +93,7 @@ public function __construct($standDataPath, $airportLatitude, $airportLongitude, if ($maxAirportDistance) $this->maxDistanceFromAirport = $maxAirportDistance; // Load stand data into memory and parse if allowed - if ($this->loadStandData() && $parseData) { - $this->parseData(); - } + if ($this->loadStandData() && $parseData) $this->parseData(); } /** @@ -100,7 +104,8 @@ public function __construct($standDataPath, $airportLatitude, $airportLongitude, public function parseData() { $this->occupiedStandsCache = null; - $pilots = $this->getVATSIMPilots(); + $vatsimData = new VatsimData(); + $pilots = $this->getVATSIMPilots($vatsimData); if ($pilots && $this->getAircraftWithinParameters($pilots)) { $this->checkIfAircraftAreOnStand(); } @@ -111,27 +116,47 @@ public function parseData() * Useful functions */ + /** + * Returns a list of all the stands (minus hidden side stands if enabled) + * + * @return Stand[] + */ public function allStands() { return $this->stands; } - public function occupiedStands() + /** + * Returns a list of all occupied stands + * + * @param bool $assoc If true, the indexes of the array will be equal to the stand name/id. Default false + * @return Stand[] + */ + public function occupiedStands($assoc = false) { - if ($this->occupiedStandsCache) return $this->occupiedStandsCache; + if (!$this->occupiedStandsCache) { + $this->occupiedStandsCache = array_filter($this->stands, function (Stand $stand) { + return $stand->isOccupied(); + }); + } - return $this->occupiedStandsCache = array_filter($this->stands, function (Stand $stand) { - return $stand->isOccupied(); - }); + return $assoc ? $this->occupiedStandsCache : array_values($this->occupiedStandsCache); } - - public function unoccupiedStands() + /** + * Returns a list of all unoccupied stands + * + * @param bool $assoc If true, the indexes of the array will be equal to the stand name/id. Default false + * @return Stand[] + */ + public function unoccupiedStands($assoc = false) { - if ($this->unoccupiedStandsCache) return $this->unoccupiedStandsCache; + if (!$this->unoccupiedStandsCache) { + $this->unoccupiedStandsCache = array_filter($this->stands, function (Stand $stand) { + return !$stand->isOccupied(); + }); + } - return $this->unoccupiedStandsCache = array_filter($this->stands, function (Stand $stand) { - return !$stand->isOccupied(); - }); + return $assoc ? $this->unoccupiedStandsCache : array_values($this->unoccupiedStandsCache); } /* @@ -187,12 +212,11 @@ private function loadStandData() /** * Returns an array of pilots from the VATSIM data feed * + * @param VatsimData $vatsimData * @return array */ - public function getVATSIMPilots() + public function getVATSIMPilots(VatsimData $vatsimData) { - $vatsimData = new VatsimData(); - if (!$vatsimData->loadData()) { // VATSIM data file is down. return null; @@ -209,10 +233,6 @@ public function getVATSIMPilots() */ private function getAircraftWithinParameters(array $pilots) { - if (count($pilots) == 0 || (($this->airportLatitude == null) || ($this->airportLongitude == null))) { - return false; - } - $filteredAircraft = []; foreach ($pilots as $pilot) { $aircraft = new Aircraft($pilot); diff --git a/tests/Unit/AircraftTest.php b/tests/Unit/AircraftTest.php index f926628..ffe4561 100644 --- a/tests/Unit/AircraftTest.php +++ b/tests/Unit/AircraftTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit; use CobaltGrid\VatsimStandStatus\Aircraft; +use CobaltGrid\VatsimStandStatus\Stand; use Tests\TestCase; class AircraftTest extends TestCase @@ -30,6 +31,13 @@ public function testItCanGetAndSetStandIndex() $this->assertEquals('21R', $this->instance->getStandIndex()); } + public function testItCanGetStandFromStands() + { + $this->instance->setStandIndex('21R'); + $stand = new Stand('21R', 1, 1, ['L'], ''); + $this->assertEquals($stand, $this->instance->getStand(['21R' => $stand])); + } + public function testItCorrectlyReportsIfOnStand() { $this->assertFalse($this->instance->onStand()); diff --git a/tests/Unit/Libraries/CoordinateConverterTest.php b/tests/Unit/Libraries/CoordinateConverterTest.php index 7ce9009..b08136e 100644 --- a/tests/Unit/Libraries/CoordinateConverterTest.php +++ b/tests/Unit/Libraries/CoordinateConverterTest.php @@ -5,6 +5,7 @@ use CobaltGrid\VatsimStandStatus\Libraries\CAACoordinateConverter; +use CobaltGrid\VatsimStandStatus\Libraries\CoordinateConverter; use Tests\TestCase; class CoordinateConverterTest extends TestCase @@ -21,4 +22,22 @@ public function testItCanConvertCAACoordinates() $this->assertEquals(-0.1648138888888889, $converter->longitudeToDecimal()); $this->assertEquals(0.1648138888888889, $converter2->longitudeToDecimal()); } + + public function testDMSConversion() + { + $class = new class extends CoordinateConverter{ + public function latitudeToDecimal() + { + return $this->convertDMSToDecimal(50, 10, 10); + } + + public function longitudeToDecimal() + { + return $this->convertDMSToDecimal(-50, 10, 10); + } + }; + + $this->assertEquals(50.169444444444444, $class->latitudeToDecimal()); + $this->assertEquals(-50.169444444444444, $class->longitudeToDecimal()); + } } \ No newline at end of file diff --git a/tests/Unit/StandStatusTest.php b/tests/Unit/StandStatusTest.php index 886bc9c..f779c19 100644 --- a/tests/Unit/StandStatusTest.php +++ b/tests/Unit/StandStatusTest.php @@ -10,7 +10,9 @@ use CobaltGrid\VatsimStandStatus\Exceptions\UnableToParseStandDataException; use CobaltGrid\VatsimStandStatus\Stand; use CobaltGrid\VatsimStandStatus\StandStatus; +use Mockery\Mock; use Tests\TestCase; +use Vatsimphp\VatsimData; class StandStatusTest extends TestCase { @@ -77,6 +79,20 @@ protected function setUp(): void $this->instance->shouldReceive('getVATSIMPilots')->andReturn($this->testPilots); } + public function testItCanParseAutomatically() + { + $mock = \Mockery::mock(StandStatus::class)->makePartial(); + $mock->shouldReceive('getVATSIMPilots')->andReturn($this->testPilots); + $mock->shouldReceive('parseData')->once(); + $mock->__construct( + $this->standDataFileCAA, + 51.148056, + -0.190278, + null, + StandStatus::COORD_FORMAT_CAA + ); + } + public function testItThrowsWithInvalidCoordinates() { $this->expectException(CoordinateOutOfBoundsException::class); @@ -101,6 +117,25 @@ public function testItParsesOk() $this->assertCount(186, $this->instance->allStands()); } + public function testItCanGetVATSIMPilots() + { + $mock = \Mockery::mock(VatsimData::class); + $mock->shouldReceive('loadData')->andReturn(true); + $mock->shouldReceive('getPilots->toArray')->andReturn($this->testPilots); + + $instance = $this->createNewInstance(); + $this->assertIsArray($instance->getVATSIMPilots($mock)); + } + + public function testGetVATSIMPilotsReturnsNullIfLoadDataFails() + { + $mock = \Mockery::mock(VatsimData::class); + $mock->shouldReceive('loadData')->andReturn(false); + + $instance = $this->createNewInstance(); + $this->assertNull($instance->getVATSIMPilots($mock)); + } + public function testItFiltersPilotsCorrectly() { $this->instance->parseData(); @@ -109,6 +144,22 @@ public function testItFiltersPilotsCorrectly() }, $this->instance->getAllAircraft())); } +// /** @only */ +// public function testItReturnsEmptyIfNoMatchesForAircraftSearch() +// { +// $instance = \Mockery::mock(StandStatus::class, [ +// $this->standDataFileCAA, +// 51.148056, +// -0.190278, +// null, +// StandStatus::COORD_FORMAT_CAA, +// false +// ])->makePartial(); +// $instance->shouldReceive('getVATSIMPilots')->andReturn([]); +// $instance->parseData(); +// $this->assertEmpty($instance->getAllAircraft()); +// } + public function testItAssignsStandsCorrectly() { $this->instance->parseData(); @@ -116,4 +167,55 @@ public function testItAssignsStandsCorrectly() $this->assertNull($this->instance->getAllAircraft()[1]->getStandIndex()); $this->assertEquals('43N', $this->instance->getAllAircraft()[2]->getStandIndex()); } + + public function testItReturnsListOfOccupiedStands() + { + $this->instance->parseData(); + $this->assertCount(1, $this->instance->occupiedStands()); + $this->assertEquals('TEST5', $this->instance->occupiedStands()[0]->occupier->callsign); + $this->assertEquals('TEST5', $this->instance->occupiedStands(true)['43N']->occupier->callsign); + } + + public function testItReturnsListOfUnoccupiedStands() + { + $this->instance->parseData(); + // 186 stands, 1 occupied which includes 3 side stands = 183 + $this->assertCount(182, $this->instance->unoccupiedStands()); + $this->assertNull($this->instance->unoccupiedStands()[0]->occupier); + $this->assertNull($this->instance->unoccupiedStands(true)['42']->occupier); + } + + public function testGettersAndSetters() + { + $instance = $this->createNewInstance(); + + $this->assertInstanceOf(StandStatus::class, $instance->setMaxStandDistance(1.11)); + $this->assertEquals(1.11, $instance->getMaxStandDistance()); + + $this->assertInstanceOf(StandStatus::class, $instance->setHideStandSidesWhenOccupied(false)); + $this->assertFalse($instance->getHideStandSidesWhenOccupied()); + + $this->assertInstanceOf(StandStatus::class, $instance->setMaxDistanceFromAirport(1.11)); + $this->assertEquals(1.11, $instance->getMaxDistanceFromAirport()); + + $this->assertInstanceOf(StandStatus::class, $instance->setMaxAircraftAltitude(111)); + $this->assertEquals(111, $instance->getMaxAircraftAltitude()); + + $this->assertInstanceOf(StandStatus::class, $instance->setMaxAircraftGroundspeed(11)); + $this->assertEquals(11, $instance->getMaxAircraftGroundspeed()); + + $this->assertInstanceOf(StandStatus::class, $instance->setStandExtensions(['A', 'B', 'C'])); + $this->assertEquals(['A', 'B', 'C'], $instance->getStandExtensions()); + } + + private function createNewInstance() + { + return new StandStatus( + $this->standDataFileCAA, + 51.148056, + -0.190278, + null, + StandStatus::COORD_FORMAT_CAA, + false); + } } \ No newline at end of file diff --git a/tests/Unit/StandTest.php b/tests/Unit/StandTest.php index 7c30f8d..21f25c7 100644 --- a/tests/Unit/StandTest.php +++ b/tests/Unit/StandTest.php @@ -80,6 +80,14 @@ public function testItCanDetermineRoot() $this->assertEquals('73', $stand3->getRoot()); } + public function testItFailsGracefullyIfNoMatches() + { + $stand = new Stand('144R', $this->standLatitude, $this->standLongitude, $this->standExtensions, ''); + + $this->assertNull($stand->getRoot()); + $this->assertNull($stand->getExtension()); + } + public function testItCanDetermineExtension() { $stand2 = new Stand('144', $this->standLatitude, $this->standLongitude, $this->standExtensions, $this->standPattern); From 62c14cbdbe797057f6cf66b5900c733a7e2e174c Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Thu, 30 Apr 2020 14:55:59 +0100 Subject: [PATCH 14/25] Fix example and update readme slightly --- README.md | 8 ++++---- examples/egkkStands.php | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 32ab802..280cbd6 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,19 @@ ## About #### Description -vatsim-stand-status is a fairly lightweight library to allow correlation of aircraft on the VATSIM network with known airport stand coordinates. +vatsim-stand-status is a lightweight PHP library to allow the correlation between aircraft on the VATSIM flight simulation network, and an airport stand. -Data is retrieved from the offical VATSIM network data sources through the use of [Skymeyer's Vatsimphp](https://github.com/skymeyer/Vatsimphp) libaray. +VATSIM network data is downloaded and parsed by [Skymeyer's Vatsimphp](https://github.com/skymeyer/Vatsimphp) library. #### Requirements -* PHP 5.3.29 and above (For skymeyer/Vatsimphp) +* PHP 7.2 and above #### Author This package was created by [Alex Toff](https://alextoff.uk) #### License -vatsim-stand-status is licensed under the GNU General Public License v3.0, which can be found in the root of the package in the `LICENSE` file. +`vatsim-stand-status` is licensed under the GNU General Public License v3.0, which can be found in the root of the package in the `LICENSE` file. ## Installation diff --git a/examples/egkkStands.php b/examples/egkkStands.php index d9befff..76fd930 100644 --- a/examples/egkkStands.php +++ b/examples/egkkStands.php @@ -4,7 +4,7 @@ require_once '../vendor/autoload.php'; -$StandStatus = new StandStatus(dirname(__FILE__) . "../tests/Fixtures/SampleData/egkkstands.csv", 51.148056, -0.190278, null, StandStatus::COORD_FORMAT_CAA); +$StandStatus = new StandStatus(dirname(__FILE__) . "/../tests/Fixtures/SampleData/egkkstands.csv", 51.148056, -0.190278, null, StandStatus::COORD_FORMAT_CAA); ?> From f8a27925aa585f6949e3f35d40a5b88e4bec78d6 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Fri, 1 May 2020 00:43:47 +0100 Subject: [PATCH 15/25] Update README --- .github/docs/images/readme-header.png | Bin 0 -> 562248 bytes README.md | 295 ++++++++++++++------------ src/StandStatus.php | 20 +- 3 files changed, 181 insertions(+), 134 deletions(-) create mode 100644 .github/docs/images/readme-header.png diff --git a/.github/docs/images/readme-header.png b/.github/docs/images/readme-header.png new file mode 100644 index 0000000000000000000000000000000000000000..f088b962d200d1efd08807459ff614687c4ee8ac GIT binary patch literal 562248 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 + + + + + + + + + + + + + + + + + + +
+
+
+
Occupied Stands
+ + + + + + occupiedStands() as $stand) { + ?> + + + + + +
StandOccupied By
getName() ?>occupier->callsign ?>
+
+
+
+
+
+
+
+
All Stands
+ Stand Data © OpenStreetMap Contributors + + + + + + + + + + + stands() as $stand) { ?> + + + + + + + + +
StandLatitudeLongitudeOccupier
getName() ?>latitude ?>longitude ?>isOccupied() ? $stand->occupier->callsign : null ?>
+
+
+
Aircraft On The Ground
+
+ getAllAircraft() as $pilot) { + if($pilot->onStand()){ + echo "
{$pilot->callsign} ({$pilot->latitude},{$pilot->longitude}) (Stand {$pilot->getStandIndex()})
"; + }else{ + echo "
{$pilot->callsign} ({$pilot->latitude},{$pilot->longitude}) (Not on stand)
"; + } + } + ?> +
+
+
+
+ + diff --git a/src/Exceptions/InvalidCoordinateFormat.php b/src/Exceptions/InvalidCoordinateFormat.php new file mode 100644 index 0000000..dcbd1ea --- /dev/null +++ b/src/Exceptions/InvalidCoordinateFormat.php @@ -0,0 +1,8 @@ += -90; + } + + /** + * Validates a given longitude coordinate to make sure it is realistic + * + * @param float $coordinate Coordinate is decimal format + * @return bool + */ + public static function validateLongitudeCoordinate($coordinate) + { + return $coordinate <= 180 && $coordinate >= -180; + } + + /** + * Return the distance in kilometres between two sets of coordinates using the haversine formula + * + * @param float $latitude1 Latitude in decimal format + * @param float $longitude1 Longitude in decimal format + * @param float $latitude2 Latitude in decimal format + * @param float $longitude2 Longitude in decimal format + * @return float|int + */ + public static function distanceBetweenCoordinates($latitude1, $longitude1, $latitude2, $longitude2) + { + $earth_radius = 6371; + + $latitude1 = floatval($latitude1); + $longitude1 = floatval($longitude1); + $latitude2 = floatval($latitude2); + $longitude2 = floatval($longitude2); + + $dLat = deg2rad($latitude2 - $latitude1); + $dLon = deg2rad($longitude2 - $longitude1); + + $a = sin($dLat / 2) * sin($dLat / 2) + cos(deg2rad($latitude1)) * cos(deg2rad($latitude2)) * sin($dLon / 2) * sin($dLon / 2); + $c = 2 * asin(sqrt($a)); + return $earth_radius * $c; + } + + /** + * Return the distance in kilometres between two sets of coordinates using the haversine formula + * + * @param float $latitude Latitude in decimal format + * @param float $longitude Longitude in decimal format + * @param float|int $distance Distance in km + * @param int $direction Clockwise direction in degrees, where north is 0 degrees, east is 90 degrees, etc. + * @return object Object with properties `latitude` and `longitude` accessible + */ + public static function distanceFromCoordinate($latitude, $longitude, $distance, $direction) + { + $earth_radius = 6371; + + $latitude = floatval($latitude) * pi()/180; + $longitude = floatval($longitude) * pi()/180; + $delta = $distance/$earth_radius; + $directionRad = $direction * pi()/180; + + $newLatitudeRad = asin(sin($latitude)*cos($delta) + cos($latitude)*sin($delta)*cos($directionRad)); + $newLongitudeRad = $longitude + atan2(sin($directionRad)*sin($delta)*cos($latitude), cos($delta) - sin($latitude)*sin($newLatitudeRad)); + + return (object) ['latitude' => $newLatitudeRad * 180/pi(), 'longitude' => $newLongitudeRad * 180/pi()]; + } +} \ No newline at end of file diff --git a/src/Libraries/OSMStandData.php b/src/Libraries/OSMStandData.php new file mode 100644 index 0000000..97ce237 --- /dev/null +++ b/src/Libraries/OSMStandData.php @@ -0,0 +1,210 @@ +airportICAO = $icao; + $this->cacheFolder = dirname(__FILE__) . '/../../storage/data'; + if ($client) $this->client = $client; else $this->client = new Client(); + } + + /** + * Fetches stand data from the Overpass API. + * + * If no results found, this function will return null. + * Otherwise, will return the absolute path to the created CSV. + * + * @param float $centerLatitude The defined airport center latitude + * @param float $centerLongitude The defined airport center longitude + * @param int $radius The radius in km from which to create the bounding box for the search + * @return string|null + */ + public function fetchStandData($centerLatitude, $centerLongitude, $radius = 6) + { + if ($this->cachedCSVPath()) { + return $this->cachedCSVPath(); + } + + // Download data from OSM API + $result = $this->client->get($this->overpassAPIUrl . $this->composeQuery($centerLatitude, $centerLongitude, $radius)); + $body = $result->getBody()->getContents(); + + // Parse the rows + $csv = str_getcsv($body, "\n"); + $stands = []; + // Check and remove duplicates + foreach ($csv as $index => $row) { + // Handle header row + if ($index == 0) { + $csv[$index] = "id,latitude,longitude,This data was extracted from the OpenStreetMap API and is licensed under the ODbL license. It's use must be attributed"; + continue; + } + $row = str_getcsv($row); + + // name tag from data - fallback name for stand id + $standName = $row[1]; + $featureType = $row[2]; + // Ref tag from data - primarily used as the stand id + $standRef = $row[0] ?? $standName; + + // Check if stand has already been processed (i.e. this is a duplicate) + if (($key = array_search($standRef, $stands)) !== false) { + + // If the stand name tag is null, is same as the ref tag, or is already in the stand array + if (!$standName || $standName == $standRef || in_array($standName, $stands)) { + // Allow nodes to take preference + if ($featureType == "node") { + // Remove the already registered stand from the registered list, and the CSV + unset($stands[$key]); + unset($csv[$key]); + } else { + // Remove this stand from the CSV + unset($csv[$index]); + continue; + } + }else{ + // We can use the stand name instead + $standRef = $standName; + } + } + + $csv[$index] = implode(',', [$standRef, $row[3], $row[4]]); + $stands[$index] = $standRef; + } + + $csv = implode("\n", $csv); + + // Store file + file_put_contents($this->getCacheFilePath(), $csv, LOCK_EX); + return $this->cachedCSVPath(); + } + + /** + * Deletes the cached file (if exists) + * + * @return bool Returns true if deleted, or false if not deleted / error. + */ + public function deleteCachedData() + { + return file_exists($this->getCacheFilePath()) ? unlink($this->getCacheFilePath()) : false; + } + + /** + * Composes the Overpass Query Language Query + * + * @param $centerLatitude + * @param $centerLongitude + * @param $radius + * @return string URL Encoded Query + */ + private function composeQuery($centerLatitude, $centerLongitude, $radius) + { + $diagonalRadius = $radius / cos(deg2rad(45)); + + // Set a hard limit for radius + if ($diagonalRadius > 20) { + $diagonalRadius = 20; + } + + // Generate bounding box following South, West, North, East format + $bottomLeft = DecimalCoordinateHelper::distanceFromCoordinate($centerLatitude, $centerLongitude, $diagonalRadius, 180 + 45); + $topRight = DecimalCoordinateHelper::distanceFromCoordinate($centerLatitude, $centerLongitude, $diagonalRadius, 45); + + return urlencode(" + [bbox:{$bottomLeft->latitude},{$bottomLeft->longitude},{$topRight->latitude},{$topRight->longitude}] + [out:csv(ref,name,::type,::lat,::lon;true;\",\")][timeout:{$this->timeout}]; + ( + nwr[aeroway=aerodrome][icao=\"{$this->airportICAO}\"]; + ); + map_to_area; + nwr[aeroway=parking_position][~\"^name|ref?$\"~\".\"](area); + out tags center; + "); + } + + /** + * Returns either the path to the cached CSV file, or null in the case of no / expired cache. + * + * @return string|null + */ + private function cachedCSVPath() + { + $fileExists = file_exists($this->getCacheFilePath()); + $fileUpdatedWithinCacheTTL = $fileExists && filemtime($this->getCacheFilePath()) > (time() - $this->cacheTTL); + + return $fileExists && $fileUpdatedWithinCacheTTL ? $this->getCacheFilePath() : null; + } + + /** + * Returns the path to the stand stand CSV file, assuming it exists + * + * @return string + */ + public function getCacheFilePath() + { + return $this->cacheFolder . "/OSM-{$this->airportICAO}-stand-data.csv"; + } + + /** + * Set the length of time to cache stand data files + * + * @param int $cacheTTL Time in seconds + * @return OSMStandData + */ + public function setCacheTTL($cacheTTL) + { + $this->cacheTTL = $cacheTTL; + return $this; + } + + /** + * Set the timeout length for retrieving data from Overpass API. If you are expecting a large data return, + * you could increase this to prevent timeouts. + * + * @param int $timeout + * @return OSMStandData + */ + public function setTimeout(int $timeout) + { + $this->timeout = $timeout; + return $this; + } + +} \ No newline at end of file diff --git a/src/StandStatus.php b/src/StandStatus.php index 2b5c9a7..7bdaa74 100644 --- a/src/StandStatus.php +++ b/src/StandStatus.php @@ -3,10 +3,15 @@ namespace CobaltGrid\VatsimStandStatus; use CobaltGrid\VatsimStandStatus\Exceptions\CoordinateOutOfBoundsException; +use CobaltGrid\VatsimStandStatus\Exceptions\InvalidCoordinateFormat; +use CobaltGrid\VatsimStandStatus\Exceptions\InvalidICAOCodeException; +use CobaltGrid\VatsimStandStatus\Exceptions\InvalidStandException; use CobaltGrid\VatsimStandStatus\Exceptions\NoStandDataException; use CobaltGrid\VatsimStandStatus\Exceptions\UnableToLoadStandDataFileException; use CobaltGrid\VatsimStandStatus\Exceptions\UnableToParseStandDataException; use CobaltGrid\VatsimStandStatus\Libraries\CAACoordinateConverter; +use CobaltGrid\VatsimStandStatus\Libraries\DecimalCoordinateHelper; +use CobaltGrid\VatsimStandStatus\Libraries\OSMStandData; use Vatsimphp\VatsimData; class StandStatus @@ -33,6 +38,7 @@ class StandStatus */ + // Coordinates of format 51.12345 const COORD_FORMAT_DECIMAL = 1; // Coordinates of format 521756.91N const COORD_FORMAT_CAA = 2; @@ -78,7 +84,7 @@ public function __construct( $this->airportLatitude = $airportLatitude; $this->airportLongitude = $airportLongitude; if ($standCoordinateFormat) $this->standCoordinateFormat = $standCoordinateFormat; - $this->validateCoordinatePairOrFail($airportLatitude, $airportLongitude); + DecimalCoordinateHelper::validateCoordinatePairOrFail($airportLatitude, $airportLongitude); } /** @@ -120,21 +126,44 @@ public function loadStandDataFromCSV(string $filePath) break; } - $this->validateCoordinatePairOrFail($latitude, $longitude); + DecimalCoordinateHelper::validateCoordinatePairOrFail($latitude, $longitude); $this->addStand($name, $latitude, $longitude); } - fclose($standDataStream); return $this; } + /** + * Fetches parking position data from the OpenStreetMap's Overpass API. + * + * It should be noted that OSM will not always have all the data needed for every airport, and some of the data may + * be incorrect, out of date or ill-formatted. Check on the OpenStreetMap's editor to ensure your airport has coverage. + * + * OpenStreetMap data is licensed under the ODbL license. If you use this method, you MUST provide appropriate + * attribution to OSM. See https://www.openstreetmap.org/copyright for details. + * + * @param string $icao 4 letter ICAO code for the airport + * @param OSMStandData $osmDataLibrary Optional. Inject the OSMStandData library if you would like to change the default settings + * @return StandStatus + * @throws CoordinateOutOfBoundsException|InvalidStandException|InvalidStandException|InvalidCoordinateFormat|UnableToLoadStandDataFileException|UnableToParseStandDataException|InvalidICAOCodeException + */ + public function fetchAndLoadStandDataFromOSM($icao, OSMStandData $osmDataLibrary = null) + { + if ($this->standCoordinateFormat != self::COORD_FORMAT_DECIMAL) { + throw new InvalidCoordinateFormat('To use OSM map data, the stand coordinate format must be set to decimal!'); + } + + if (!$osmDataLibrary) $osmDataLibrary = new OSMStandData($icao); + $csvPath = $osmDataLibrary->fetchStandData($this->airportLatitude, $this->airportLongitude, $this->maxDistanceFromAirport * 3); + return $this->loadStandDataFromCSV($csvPath); + } + /** * Loads in stand data from an array. * * @param array $standData Array of stands. Expects each stand to have the name/id at index 0, latitude at index 1, and longitude at index 2 * @return StandStatus - * @throws Exceptions\InvalidStandException - * @throws UnableToParseStandDataException + * @throws InvalidStandException|UnableToParseStandDataException */ public function loadStandDataFromArray(array $standData) { @@ -155,7 +184,7 @@ public function loadStandDataFromArray(array $standData) public function parseData() { // If no stands loaded, throw - if(count($this->stands) == 0){ + if (count($this->stands) == 0) { throw new NoStandDataException(); } @@ -168,7 +197,7 @@ public function parseData() $pilots = $this->getVATSIMPilots($vatsimData); // Clear existing matches - foreach ($this->stands as &$stand){ + foreach ($this->stands as &$stand) { $stand->clearParsedData(); } @@ -191,10 +220,10 @@ public function parseData() */ public function stands($assoc = false) { - if(!$this->hideStandSidesWhenOccupied){ + if (!$this->hideStandSidesWhenOccupied) { return $this->allStands($assoc); } - return array_filter($this->allStands($assoc), function (Stand $stand){ + return array_filter($this->allStands($assoc), function (Stand $stand) { return !$stand->isPartOfOccupiedGroup(); }); } @@ -287,7 +316,7 @@ private function getAircraftWithinParameters(array $pilots) foreach ($pilots as $pilot) { $aircraft = new Aircraft($pilot); - $insideAirfieldRange = $this->distanceBetweenCoordinates($aircraft->latitude, $aircraft->longitude, $this->airportLatitude, $this->airportLongitude) + $insideAirfieldRange = DecimalCoordinateHelper::distanceBetweenCoordinates($aircraft->latitude, $aircraft->longitude, $this->airportLatitude, $this->airportLongitude) < $this->maxDistanceFromAirport; $belowSpecifiedGroundspeed = $aircraft->groundspeed <= $this->maxAircraftGroundspeed; $belowSpecifiedAltitude = $aircraft->altitude <= $this->maxAircraftAltitude; @@ -315,7 +344,7 @@ private function checkIfAircraftAreOnStand() foreach ($this->stands as $standIndex => $stand) { // Find distance between aircraft and stand - $distance = $this->distanceBetweenCoordinates($stand->latitude, $stand->longitude, $aircraft->latitude, $aircraft->longitude); + $distance = DecimalCoordinateHelper::distanceBetweenCoordinates($stand->latitude, $stand->longitude, $aircraft->latitude, $aircraft->longitude); $distanceInsideBound = $distance < $this->maxStandDistance; @@ -408,75 +437,6 @@ private function complementaryStands(Stand $stand) } - /* - * Helpers - */ - - - /** - * Validates a given latitude/longitude pair and throws an exception if invalid - * - * @param $latitude - * @param $longitude - * @return bool - * @throws CoordinateOutOfBoundsException - */ - private function validateCoordinatePairOrFail($latitude, $longitude) - { - if ($this->validateLatitudeCoordinate($latitude) && $this->validateLongitudeCoordinate($longitude)) { - return true; - } - throw new CoordinateOutOfBoundsException; - } - - /** - * Validates a given latitude coordinate to make sure it is realistic - * - * @param float $coordinate - * @return bool - */ - private function validateLatitudeCoordinate($coordinate) - { - return $coordinate <= 90 && $coordinate >= -90; - } - - /** - * Validates a given longitude coordinate to make sure it is realistic - * - * @param float $coordinate - * @return bool - */ - private function validateLongitudeCoordinate($coordinate) - { - return $coordinate <= 180 && $coordinate >= -180; - } - - /** - * Return the distance in kilometres between two sets of coordinates - * @param float $latitude1 Latitude in decimal format - * @param float $longitude1 Longitude in decimal format - * @param float $latitude2 Latitude in decimal format - * @param float $longitude2 Longitude in decimal format - * @return float|int - */ - private function distanceBetweenCoordinates($latitude1, $longitude1, $latitude2, $longitude2) - { - $earth_radius = 6371; - - $latitude1 = floatval($latitude1); - $longitude1 = floatval($longitude1); - $latitude2 = floatval($latitude2); - $longitude2 = floatval($longitude2); - - $dLat = deg2rad($latitude2 - $latitude1); - $dLon = deg2rad($longitude2 - $longitude1); - - $a = sin($dLat / 2) * sin($dLat / 2) + cos(deg2rad($latitude1)) * cos(deg2rad($latitude2)) * sin($dLon / 2) * sin($dLon / 2); - $c = 2 * asin(sqrt($a)); - return $earth_radius * $c; - } - - /* * Getters and Setters */ diff --git a/tests/Fixtures/OSMExample/exampleAPIResponse.csv b/tests/Fixtures/OSMExample/exampleAPIResponse.csv new file mode 100644 index 0000000..75fd23b --- /dev/null +++ b/tests/Fixtures/OSMExample/exampleAPIResponse.csv @@ -0,0 +1,8 @@ +ref,name,@type,@lat,@lon,Data copyright OpenStreetMap Contributors +520,,way,51.4690687,-0.4864827 +520,,node,51.4693779,-0.4864854 +255L,255L,way,51.4709921,-0.4396740 +255L,255L,way,51.4708921,-0.4398740 +255L,254,way,51.4709921,-0.4396740 +254L,254L,way,51.4711742,-0.4398292 +253R,253R,way,51.4699992,-0.4396253 diff --git a/tests/Fixtures/OSMExample/expectedOutput.csv b/tests/Fixtures/OSMExample/expectedOutput.csv new file mode 100644 index 0000000..7620aca --- /dev/null +++ b/tests/Fixtures/OSMExample/expectedOutput.csv @@ -0,0 +1,6 @@ +id,latitude,longitude,This data was extracted from the OpenStreetMap API and is licensed under the ODbL license. It's use must be attributed +520,51.4693779,-0.4864854 +255L,51.4709921,-0.4396740 +254,51.4709921,-0.4396740 +254L,51.4711742,-0.4398292 +253R,51.4699992,-0.4396253 \ No newline at end of file diff --git a/tests/Integration/OSMIntegrationTest.php b/tests/Integration/OSMIntegrationTest.php new file mode 100644 index 0000000..12fed70 --- /dev/null +++ b/tests/Integration/OSMIntegrationTest.php @@ -0,0 +1,34 @@ +expectException(InvalidCoordinateFormat::class); + $instance = new StandStatus(51.148056,-0.190278, StandStatus::COORD_FORMAT_CAA); + $instance->fetchAndLoadStandDataFromOSM('EGKK'); + } + + public function testItCanLoadStandDataFromOSM() + { + // Delete file if has + $OSMInstance = new OSMStandData('EGKK'); + $OSMInstance->deleteCachedData(); + + $instance = new StandStatus(51.148056,-0.190278); + $instance->fetchAndLoadStandDataFromOSM('EGKK'); + $this->assertTrue(count($instance->stands()) > 1); + + // Delete data file generated + $OSMInstance->deleteCachedData(); + } +} \ No newline at end of file diff --git a/tests/Integration/StandStatusIntegrationTest.php b/tests/Integration/StandStatusIntegrationTest.php new file mode 100644 index 0000000..ff618bf --- /dev/null +++ b/tests/Integration/StandStatusIntegrationTest.php @@ -0,0 +1,18 @@ +expectNotToPerformAssertions(); + $instance = new StandStatus(51.148056,-0.190278, StandStatus::COORD_FORMAT_CAA); + $instance->loadStandDataFromCSV(dirname(__FILE__) . '/../Fixtures/SampleData/egkkstands.csv')->parseData(); + } +} \ No newline at end of file diff --git a/tests/Unit/Libraries/DecimalCoordinateHelperTest.php b/tests/Unit/Libraries/DecimalCoordinateHelperTest.php new file mode 100644 index 0000000..a0af7fe --- /dev/null +++ b/tests/Unit/Libraries/DecimalCoordinateHelperTest.php @@ -0,0 +1,66 @@ +expectException(CoordinateOutOfBoundsException::class); + DecimalCoordinateHelper::validateCoordinatePairOrFail(1000, 0.1); + } + + public function testItCanValidateALatitudeCoordinate() + { + $this->assertTrue(DecimalCoordinateHelper::validateLatitudeCoordinate(0)); + $this->assertTrue(DecimalCoordinateHelper::validateLatitudeCoordinate(-1.2123413)); + $this->assertTrue(DecimalCoordinateHelper::validateLatitudeCoordinate(89.00123)); + $this->assertTrue(DecimalCoordinateHelper::validateLatitudeCoordinate(90)); + $this->assertTrue(DecimalCoordinateHelper::validateLatitudeCoordinate(-90)); + + $this->assertFalse(DecimalCoordinateHelper::validateLatitudeCoordinate(90.1)); + $this->assertFalse(DecimalCoordinateHelper::validateLatitudeCoordinate(-90.1)); + } + + public function testItCanValidateALongitudeCoordinate() + { + $this->assertTrue(DecimalCoordinateHelper::validateLongitudeCoordinate(0)); + $this->assertTrue(DecimalCoordinateHelper::validateLongitudeCoordinate(156.01231)); + $this->assertTrue(DecimalCoordinateHelper::validateLongitudeCoordinate(-156.0123)); + $this->assertTrue(DecimalCoordinateHelper::validateLongitudeCoordinate(180)); + $this->assertTrue(DecimalCoordinateHelper::validateLongitudeCoordinate(-180)); + + $this->assertFalse(DecimalCoordinateHelper::validateLongitudeCoordinate(180.1)); + $this->assertFalse(DecimalCoordinateHelper::validateLongitudeCoordinate(-180.1)); + } + + public function testItCanFindDistanceBetweenTwoCoordinates() + { + // Moscow to New York + $this->assertEquals(7512, round(DecimalCoordinateHelper::distanceBetweenCoordinates(...array_merge($this->moscowCoords, $this->newYorkCoords)))); + + // Flip direction + $this->assertEquals(7512, round(DecimalCoordinateHelper::distanceBetweenCoordinates(...array_merge($this->newYorkCoords,$this->moscowCoords)))); + } + + public function testItCanFindCoordinatesFromDistanceAndBearing() + { + $set1 = DecimalCoordinateHelper::distanceFromCoordinate($this->newYorkCoords[0], $this->newYorkCoords[1], 10, 45); + $set2 = DecimalCoordinateHelper::distanceFromCoordinate($this->newYorkCoords[0], $this->newYorkCoords[1], 10, 195); + $this->assertEquals(40.72456128648007, $set1->latitude); + $this->assertEquals(-73.86008993799598, $set1->longitude); + + $this->assertEquals(40.574128150080696, $set2->latitude); + $this->assertEquals( -73.9746440460457, $set2->longitude); + } + +} \ No newline at end of file diff --git a/tests/Unit/Libraries/OSMStandDataTest.php b/tests/Unit/Libraries/OSMStandDataTest.php new file mode 100644 index 0000000..ad7916b --- /dev/null +++ b/tests/Unit/Libraries/OSMStandDataTest.php @@ -0,0 +1,90 @@ +instance = new OSMStandData('EGKK'); + + // Delete any generated files + $this->instance->deleteCachedData(); + } + + public function testItThrowsIfInvalidICAOCode() + { + $this->expectException(InvalidICAOCodeException::class); + new OSMStandData('1234'); + + $this->expectException(InvalidICAOCodeException::class); + new OSMStandData('EGK'); + } + + public function testItCanBeConstructed() + { + $this->expectNotToPerformAssertions(); + new OSMStandData('EGKK', new Client()); + } + + public function testItDefersToCachedData() + { + $client = Mockery::spy(Client::class); + $instance = new OSMStandData('EGKK', $client); + // Insert the file + file_put_contents($this->instance->getCacheFilePath(), ''); + + $this->assertEquals($this->instance->getCacheFilePath(), $instance->fetchStandData(1,1)); + $client->shouldNotHaveBeenCalled(); + } + + public function testItCanParseExampleAPIResponse() + { + $responseFile = file_get_contents(dirname(__FILE__).'/../../Fixtures/OSMExample/exampleAPIResponse.csv'); + $parsedFile = str_replace("\r", "", file_get_contents(dirname(__FILE__).'/../../Fixtures/OSMExample/expectedOutput.csv')); + + $client = Mockery::mock(Client::class); + $client->shouldReceive('get->getBody->getContents') + ->andReturn($responseFile); + + $instance = new OSMStandData('EGKK', $client); + + $this->assertEquals($this->instance->getCacheFilePath(), $instance->fetchStandData(1, 1, 20)); + $this->assertEquals($parsedFile, file_get_contents($this->instance->getCacheFilePath())); + } + + public function testItCanSetCacheTTL() + { + $this->assertInstanceOf(OSMStandData::class, $this->instance->setCacheTTL(60)); + } + + public function testItCanSetTimeout() + { + $this->assertInstanceOf(OSMStandData::class, $this->instance->setTimeout(60)); + } + + public function testItCanDeleteCachedData() + { + // Insert the file + file_put_contents($this->instance->getCacheFilePath(), ''); + + $this->assertTrue($this->instance->deleteCachedData()); + + // No file to delete, expect false + $this->assertFalse($this->instance->deleteCachedData()); + } + +} \ No newline at end of file From 92ea817aefb5e1a56fd47af863e25c1e3c2e40c4 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Sun, 3 May 2020 17:06:55 +0100 Subject: [PATCH 22/25] Add storage directory --- .gitignore | 2 +- storage/data/.gitkeep | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 storage/data/.gitkeep diff --git a/.gitignore b/.gitignore index 9f6c9b6..326be2a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ composer.lock .idea build .phpunit.result.cache -storage/data/* \ No newline at end of file +storage/data/*.csv \ No newline at end of file diff --git a/storage/data/.gitkeep b/storage/data/.gitkeep new file mode 100644 index 0000000..e69de29 From 4a9d586269c6b45a48d7f1079c5da790586adb15 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Sun, 3 May 2020 18:43:55 +0100 Subject: [PATCH 23/25] Fix TOC --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d6530b7..0b7ad7e 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ + [Construct an Instance](#construct-an-instance) + [Loading Stand Data](#loading-stand-data) - [1. Loading from a CSV](#1-loading-from-a-csv) - - [2. Loading from an array](#2-loading-from-an-array) - - [3. Loading from OpenStreetMap (OSM)](#3-loading-from-openstreetmap--osm-) + - [2. Loading from an Array](#2-loading-from-an-array) + - [3. Loading from OpenStreetMap (OSM)](#3-loading-from-openstreetmap) + [Parsing the Data](#parsing-the-data) * [Data Types](#data-types) * [Examples](#examples) @@ -170,7 +170,7 @@ In the end, you should have a CSV file that looks something like this (For a CAA | 2 | 510915.83N | 0000952.81W | | 3 | 510914.31N | 0000952.28W | -#### 2. Loading from an array +#### 2. Loading from an Array Alternatively, you can load in stand data through an array that follows the format id, latitude, longitude: ```php @@ -184,9 +184,9 @@ Alternatively, you can load in stand data through an array that follows the form Again, make sure you set the correct stand coordinate format in the constructor. -#### 3. Loading from OpenStreetMap (OSM) +#### 3. Loading from OpenStreetMap -This option leverages the powerful OpenStreetMap [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) to attempt to find and download stand data that has been contributed on OSM. Unlike the other two methods, you don't have to do any data-digging at all, and just have to specify the ICAO code of the airport. +This option leverages the powerful OpenStreetMap (OSM) [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) to attempt to find and download stand data that has been contributed on OSM. Unlike the other two methods, you don't have to do any data-digging at all, and just have to specify the ICAO code of the airport. Some __important__ notes: * OSM data is constantly being edited. There is never any guarantee the data is accurate, or that it will stay up to date with changes. You should check the airport your want to use this method for has data available by taking a look on the [OSM web editor](https://www.openstreetmap.org/edit). If it have no / limited data, change it yourself! Contribute to #opensource. From 1bf9ac99df051d0d59300ebb35f9aaf00686e950 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Sun, 3 May 2020 18:44:49 +0100 Subject: [PATCH 24/25] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0b7ad7e..4b38bcb 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Again, make sure you set the correct stand coordinate format in the constructor. This option leverages the powerful OpenStreetMap (OSM) [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) to attempt to find and download stand data that has been contributed on OSM. Unlike the other two methods, you don't have to do any data-digging at all, and just have to specify the ICAO code of the airport. Some __important__ notes: -* OSM data is constantly being edited. There is never any guarantee the data is accurate, or that it will stay up to date with changes. You should check the airport your want to use this method for has data available by taking a look on the [OSM web editor](https://www.openstreetmap.org/edit). If it have no / limited data, change it yourself! Contribute to #opensource. +* OSM data is constantly being edited. There is never any guarantee the data is accurate, or that it will stay up to date with changes. You should check the airport your want to use this method for has data available by taking a look on the [OSM web editor](https://www.openstreetmap.org/edit). If it has no / limited data, change it yourself! Contribute to #opensource. * OSM data is governed by the Open Data Commons Open Database License. The key take-away from this is that you ___must attribute OSM where you use the data___. Check out [their copyright site](https://www.openstreetmap.org/copyright) to find out more. * The library will cache the data retrieved for an airport when the below method is first run to reduce unnecessary calls to the API. By default, this cache will last for 3 months. * The library will search for the OSM data type `aeroways=parking_position` located inside the airfield boundary, using either the `ref` or `name` for the stand name. It will also use the latitude and longitude set in the constructor of the library, as well as the `maxDistanceFromAirport` setting to determine the search area bounding box. From 09399b80def61ede6808b79fdfe5dbf9d85f0d51 Mon Sep 17 00:00:00 2001 From: Alex Toff Date: Sun, 3 May 2020 18:50:54 +0100 Subject: [PATCH 25/25] Add test to assert is allows no results --- .../OSMExample/exampleEmptyAPIResponse.csv | 1 + tests/Unit/Libraries/OSMStandDataTest.php | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 tests/Fixtures/OSMExample/exampleEmptyAPIResponse.csv diff --git a/tests/Fixtures/OSMExample/exampleEmptyAPIResponse.csv b/tests/Fixtures/OSMExample/exampleEmptyAPIResponse.csv new file mode 100644 index 0000000..a058466 --- /dev/null +++ b/tests/Fixtures/OSMExample/exampleEmptyAPIResponse.csv @@ -0,0 +1 @@ +ref,name,@type,@lat,@lon diff --git a/tests/Unit/Libraries/OSMStandDataTest.php b/tests/Unit/Libraries/OSMStandDataTest.php index ad7916b..3d850d1 100644 --- a/tests/Unit/Libraries/OSMStandDataTest.php +++ b/tests/Unit/Libraries/OSMStandDataTest.php @@ -66,6 +66,20 @@ public function testItCanParseExampleAPIResponse() $this->assertEquals($parsedFile, file_get_contents($this->instance->getCacheFilePath())); } + public function testItAllowsEmptyCSV() + { + $responseFile = file_get_contents(dirname(__FILE__).'/../../Fixtures/OSMExample/exampleEmptyAPIResponse.csv'); + + $client = Mockery::mock(Client::class); + $client->shouldReceive('get->getBody->getContents') + ->andReturn($responseFile); + + $instance = new OSMStandData('EGKK', $client); + + $this->assertEquals($this->instance->getCacheFilePath(), $instance->fetchStandData(1, 1, 20)); + $this->assertFileExists($this->instance->getCacheFilePath()); + } + public function testItCanSetCacheTTL() { $this->assertInstanceOf(OSMStandData::class, $this->instance->setCacheTTL(60));