diff --git a/CHANGELOG.md b/CHANGELOG.md index d79d218a..ba120fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Consolidates `runSID()` and `climbViaSid()` logic - Deprecates `sid` and `star` properties of the `AirportModel` in favor of `sidCollection` and `starCollection` [#54](https://github.com/n8rzz/atc/issues/54) - Adds [Express](expressjs.com) server to serve static assets and add [travis](travis-ci.org) config file for travis continuous integration [#169](https://github.com/n8rzz/atc/issues/169) +- Rewrites the CommandParser from the ground up [#114](https://github.com/n8rzz/atc/issues/114) @@ -36,6 +37,7 @@ + ### Bugfixes - Moves `_comment` blocks in airport json file to be within object the are describing [#145](https://github.com/n8rzz/atc/issues/145) - Streamlines flight number generation and adds new method to add new callsigns to the existing list [#151](https://github.com/n8rzz/atc/issues/151) @@ -74,3 +76,5 @@ - Aircraft strips show arrival airport in uppercase [#108](https://github.com/n8rzz/atc/issues/108) - Updates `FixCollection.findFixByName()` to accept upper, mixed, or lower case fix name [#109](https://github.com/n8rzz/atc/issues/109) - Switching to a previously loaded airport does not clear previous airport fixes [#115](https://github.com/n8rzz/atc/issues/115) +- Fixes `parseElevation()` so that it does not return NaN when it is given the string `'Infinity'` [#191] (https://github.com/n8rzz/atc/issues/191) + - Originally reported under [#756](https://github.com/zlsa/atc/issues/756) \ No newline at end of file diff --git a/documentation/index.md b/documentation/index.md index d231cb9d..8143ff7b 100644 --- a/documentation/index.md +++ b/documentation/index.md @@ -30,9 +30,9 @@ other support for pilots. - [Wikipedia](https://en.wikipedia.org/wiki/Air_traffi Although the tutorial gives a large amount of information, if you find remembering the commands too complicated, here's a reference. Remember that you can type out multiple commands in one go; for example: -`BAW231 fh090 d 30 sp 180` will work as well as all three commands run -separately. Additionally, some have "shortKeys", where you can skip the -space that is normally included, like in `BAW231 fh090` (heading). +`BAW231 fh 090 d 30 sp 180` will work as well as all three commands run +separately. Some commands have "alises" that are shorter to type. An +example of that would be the `takeoff` command which has an alias `to`. ### Taxi _Aliases -_ `taxi` / `wait` / `w` @@ -65,7 +65,7 @@ on rerouting for further detail. _Syntax -_ `AAL123 star [transition].[STAR name].[airport]` -### "Cleared As Filed" +### Cleared As Filed _Aliases -_ `caf` _Information -_ This command tells the airplane that they are cleared to follow @@ -76,7 +76,7 @@ there is no need to use the `sid` command. Just clear him "as filed" with the _Syntax -_ `AAL123 caf` -### "Climb Via SID" +### Climb Via SID _Aliases -_ `cvs` _Information -_ Authorizes the aircraft to climb in accordance with the @@ -86,7 +86,7 @@ posted in the procedure. _Syntax -_ `AAL123 cvs` -### "Descend via STAR" +### Descend via STAR _Aliases -_ `dvs` _Information -_ Authorizes the aircraft to descend in accordance with the @@ -108,7 +108,9 @@ writing altitudes you would drop the last two zeros. For example, 3,000ft = "30", 8,300ft = "83", 10,000ft = "100", and FL180 (18,000ft) = "180". Airplanes will not descend below 1000 feet (unless locked on ILS). -_Syntax -_ `AAL123 c [alt]` +Altitude also accepts an `expedite` or `x` argument which can be used as the last item in the command. + +_Syntax -_ `AAL123 c [alt]` or `AAL123 c [alt] x` ### Takeoff _Aliases -_ `takeoff`, `to`, `cto` @@ -123,9 +125,9 @@ for an altitude assignment before they agree to take off. _Syntax -_ `AAL123 cto` ### Heading -_Aliases -_ `heading` / `h` / `turn` / `t` +_Aliases -_ `heading` / `h` / `turn` / `t` / `fh` -_Shortkeys -_ `fh` / `left arrow` / `right arrow` (if "Control Method" setting = "Arrow Keys") +_Shortkeys -_ `left arrow` / `right arrow` (if "Control Method" setting = "Arrow Keys") _Information -_ This command sets the target heading; up (north) is 360, right (east) is 090, down (south) is 180, and left (west) is 270. Of course @@ -134,7 +136,7 @@ before takeoff, the aircraft will turn to that heading after takeoff. You can force the aircraft to reach the heading by turning left or right by inserting `l` or `r` before the new heading, as demonstrated below. -_Syntax -_ `AAL123 fh[hdg]` or `AAL123 (rightarrow)[hdg]` or `AAL123 t r [hdg]` +_Syntax -_ `AAL123 fh [hdg]` or `AAL123 (rightarrow) [hdg]` or `AAL123 t r [hdg]` ### Speed _Aliases -_ `speed` / `slow` / `sp` @@ -146,7 +148,7 @@ their safe speeds if you tell them to fly faster or slower than they are able to. It takes some time to increase and reduce speed. Remember that speed is always expressed in knots. -_Syntax -_ `AAL123 -[spd]` or `AAL123 +[spd]` +_Syntax -_ `AAL123 - [spd]` or `AAL123 + [spd]` ### Land _Aliases -_ `ils` / `i` / `land` / `l` @@ -232,7 +234,7 @@ should probably use the more powerful `route` or `rr` commands. _Syntax -_ `AAL123 f [fixname]` -### "Proceed Direct" +### Proceed Direct _Aliases -_ `direct` / `pd` / `dct` _Information -_ This command instructs the aircraft to go direct to a @@ -277,9 +279,9 @@ is sometimes used by controllers to indicate the status of the aircraft, in reference to whether or not they have been told to do something yet (for instance, approach might move all the blocks down for a/c that have been switched to tower frequency). In this sim, you can shift it in any of the 8 -subcardinal directions, in reference to their relative position on the numpad: +subcardinal directions, in reference to their relative position on the numpad: `(8:N, 9:NE, 6:E, 3:SE, 2:S, 1:SW, 4:W, 7:NW)`. Additionally, position `5` can be used to "shortstem" the aircraft, which puts the data block right on top of the aircraft's position symbol. -_Syntax -_ ``AAL123 `2`` \ No newline at end of file +_Syntax -_ ``AAL123 `2`` diff --git a/src/assets/scripts/App.js b/src/assets/scripts/App.js index 6f09967c..6508fed7 100644 --- a/src/assets/scripts/App.js +++ b/src/assets/scripts/App.js @@ -28,9 +28,9 @@ require('./util'); require('./parser'); // saved as this.prop.version and this.prop.version_string -const VERSION = [3, 0, 0]; +const VERSION = [3, 2, 0]; -// are you using a main loop? (you must call update() afterward disable/reenable) +// are you using a main loop? (you must call update() afterward disable/re-enable) let UPDATE = true; // the framerate is updated this often (seconds) diff --git a/src/assets/scripts/InputController.js b/src/assets/scripts/InputController.js index 1c2990a2..974eaa24 100644 --- a/src/assets/scripts/InputController.js +++ b/src/assets/scripts/InputController.js @@ -1,7 +1,9 @@ /* eslint-disable camelcase, no-mixed-operators, object-shorthand, class-methods-use-this, no-undef, expected-return*/ import $ from 'jquery'; import _get from 'lodash/get'; -import _map from 'lodash/map' +import _has from 'lodash/has'; +import _map from 'lodash/map'; +import CommandParser from './commandParser/CommandParser'; import { clamp } from './math/core'; import { GAME_OPTION_NAMES } from './constants/gameOptionConstants'; import { SELECTORS } from './constants/selectors'; @@ -51,18 +53,18 @@ const MOUSE_EVENT_CODE = { * @final */ const KEY_CODES = { - // `+` + // + ADD: 107, - // `-` + // - DASH: 189, DASH_FIREFOX: 173, DIVIDE: 111, DOWN_ARROW: 40, ENTER: 13, - // `=` + // = EQUALS: 187, EQUALS_FIREFOX: 61, - // `esc` + // esc ESCAPE: 27, LEFT_ARROW: 37, MULTIPLY: 106, @@ -71,7 +73,9 @@ const KEY_CODES = { RIGHT_ARROW: 39, SUBTRACT: 109, TAB: 9, - UP_ARROW: 38 + UP_ARROW: 38, + // ` + BAT_TICK: 192 }; /** @@ -196,6 +200,7 @@ export default class InputController { * @method input_init_pre */ input_init_pre() { + // TODO: these prop properties can be removed except for `prop.input` prop.input = input; prop.input.command = ''; prop.input.callsign = ''; @@ -364,6 +369,7 @@ export default class InputController { return; } + // TODO: move to master REGEX constant let match = /^\s*(\w+)/.exec(prop.input.command); if (!match) { @@ -436,7 +442,14 @@ export default class InputController { onCommandInputKeydownHandler(e) { const currentCommandInputValue = this.$commandInput.val(); + // TODO: this swtich can be simplified, there is a lot of repetition here switch (e.which) { + case KEY_CODES.BAT_TICK: + this.$commandInput.val(`${currentCommandInputValue}\` `); + e.preventDefault(); + this.onCommandInputChangeHandler(); + + break; case KEY_CODES.ENTER: this.input_parse(); @@ -467,8 +480,8 @@ export default class InputController { case KEY_CODES.LEFT_ARROW: // shortKeys in use - if (window.gameController.game.option.get('controlMethod') === 'arrows') { - this.$commandInput.val(`${currentCommandInputValue} \u2BA2`); + if (this._isArrowControlMethod()) { + this.$commandInput.val(`${currentCommandInputValue} t l `); e.preventDefault(); this.onCommandInputChangeHandler(); } @@ -476,8 +489,8 @@ export default class InputController { break; case KEY_CODES.UP_ARROW: - if (this._isArrowControlMethod()) { // shortKeys in use - this.$commandInput.val(`${currentCommandInputValue} \u2B61`); + if (this._isArrowControlMethod()) { + this.$commandInput.val(`${currentCommandInputValue} \u2B61 `); e.preventDefault(); this.onCommandInputChangeHandler(); } else { @@ -490,7 +503,7 @@ export default class InputController { case KEY_CODES.RIGHT_ARROW: // shortKeys in use if (this._isArrowControlMethod()) { - this.$commandInput.val(`${currentCommandInputValue} \u2BA3`); + this.$commandInput.val(`${currentCommandInputValue} t r `); e.preventDefault(); this.onCommandInputChangeHandler(); } @@ -498,8 +511,8 @@ export default class InputController { break; case KEY_CODES.DOWN_ARROW: - if (this._isArrowControlMethod()) { // shortKeys in use - this.$commandInput.val(`${currentCommandInputValue} \u2B63`); + if (this._isArrowControlMethod()) { + this.$commandInput.val(`${currentCommandInputValue} \u2B63 `); e.preventDefault(); this.onCommandInputChangeHandler(); } else { @@ -511,42 +524,42 @@ export default class InputController { break; case KEY_CODES.MULTIPLY: - this.$commandInput.val(`${currentCommandInputValue} \u2B50`); + this.$commandInput.val(`${currentCommandInputValue} \u2B50 `); e.preventDefault(); this.onCommandInputChangeHandler(); break; case KEY_CODES.ADD: - this.$commandInput.val(`${currentCommandInputValue} +`); + this.$commandInput.val(`${currentCommandInputValue} + `); e.preventDefault(); this.onCommandInputChangeHandler(); break; case KEY_CODES.EQUALS: // mac + (actually `=`) - this.$commandInput.val(`${currentCommandInputValue} +`); + this.$commandInput.val(`${currentCommandInputValue} + `); e.preventDefault(); this.onCommandInputChangeHandler(); break; case KEY_CODES.SUBTRACT: - this.$commandInput.val(`${currentCommandInputValue} -`); + this.$commandInput.val(`${currentCommandInputValue} - `); e.preventDefault(); this.onCommandInputChangeHandler(); break; case KEY_CODES.DASH: // mac - - this.$commandInput.val(`${currentCommandInputValue} -`); + this.$commandInput.val(`${currentCommandInputValue} - `); e.preventDefault(); this.onCommandInputChangeHandler(); break; case KEY_CODES.DIVIDE: - this.$commandInput.val(`${currentCommandInputValue} takeoff`); + this.$commandInput.val(`${currentCommandInputValue} takeoff `); e.preventDefault(); this.onCommandInputChangeHandler(); @@ -705,89 +718,139 @@ export default class InputController { } /** + * Encapsulation of repeated boolean logic + * * @for InputController - * @method input_run + * @method _isArrowControlMethod + * @return {boolean} */ - input_run() { + _isArrowControlMethod() { + return window.gameController.game.option.get(GAME_OPTION_NAMES.CONTROL_METHOD) === 'arrows'; + } + + /** + * @for InputController + * @method _parseUserCommand + * @return result {CommandParser} + */ + _parseUserCommand() { let result; + // this could use $commandInput.val() as an alternative + const userCommand = prop.input.command.trim().toLowerCase(); - // TODO: does this need to be in a try/catch? - // TODO: abstract this to another method and only hanlde the return with this method. + // Using try/catch here very much on purpose. the `CommandParser` will throw when it encounters any kind + // of error; invalid length, validation, parse, etc. Here we catch those errors, log them to the screen + // and then throw them all at once try { - result = zlsa.atc.Parser.parse(prop.input.command.trim().toLowerCase()); + result = new CommandParser(userCommand); } catch (error) { - if (_get(error, 'name', '') === 'SyntaxError') { - window.uiController.ui_log('Command not understood'); - - return; - } + window.uiController.ui_log('Command not understood'); throw error; } - // TODO: convert `result.command === { }` to a switch statement - if (result.command === 'version') { - window.uiController.ui_log(`Air Traffic Control simulator version ${prop.version.join('.')}`); + return result; + } - return true; - } else if (result.command === 'tutorial') { - window.tutorialView.tutorial_toggle(); + /** + * @for InputController + * @method input_run + */ + input_run() { + const commandParser = this._parseUserCommand(); - return true; - } else if (result.command === 'auto') { - // TODO: this is undefined - aircraft_toggle_auto(); + if (commandParser.command !== 'transmit') { + return this.processSystemCommand(commandParser); + } - if (prop.aircraft.auto.enabled) { - window.uiController.ui_log('automatic controller ENGAGED'); - } else { - window.uiController.ui_log('automatic controller OFF'); - } + return this.processTransmitCommand(commandParser); + } - return true; - } else if (result.command === 'pause') { - window.gameController.game_pause_toggle(); - return true; - } else if (result.command === 'timewarp') { - if (result.args) { - window.gameController.game.speedup = result.args; - } else { - window.gameController.game_timewarp_toggle(); - } + /** + * @for InputController + * @method processSystemCommand + * @param commandParser {CommandParser} + * @return {boolean} + */ + processSystemCommand(commandParser) { + switch (commandParser.command) { + case PARSED_COMMAND_NAME.VERSION: + window.uiController.ui_log(`Air Traffic Control simulator version ${prop.version.join('.')}`); - return true; - } else if (result.command === 'clear') { - localStorage.clear(); - location.reload(); - } else if (result.command === 'airport') { - if (result.args) { - if (result.args.toLowerCase() in prop.airport.airports) { - window.airportController.airport_set(result.args.toLowerCase()); + return true; + + case PARSED_COMMAND_NAME.TUTORIAL: + window.tutorialView.tutorial_toggle(); + + return true; + + case PARSED_COMMAND_NAME.AUTO: + // FIXME: does this function exist anywhere? + // aircraft_toggle_auto(); + // + // if (prop.aircraft.auto.enabled) { + // window.uiController.ui_log('automatic controller ENGAGED'); + // } else { + // window.uiController.ui_log('automatic controller OFF'); + // } + + return true; + + case PARSED_COMMAND_NAME.PAUSE: + window.gameController.game_pause_toggle(); + + return true; + + case PARSED_COMMAND_NAME.TIMEWARP: + if (commandParser.args) { + window.gameController.game.speedup = commandParser.args; } else { - window.uiController.ui_airport_toggle(); + window.gameController.game_timewarp_toggle(); } - } else { - window.uiController.ui_airport_toggle(); - } - return true; - } else if (result.command === 'rate') { - if (result.args && result.args > 0) { - window.gameController.game.frequency = result.args; - } + return true; - return true; - } else if (result.command !== 'transmit') { - return true; + case PARSED_COMMAND_NAME.CLEAR: + localStorage.clear(); + location.reload(); + + case PARSED_COMMAND_NAME.AIRPORT: + // TODO: it may be better to do this in the parser + const airportIcao = commandParser.args[0]; + + if (_has(prop.airport.airports, airportIcao)) { + window.airportController.airport_set(airportIcao); + } + + return true; + + case PARSED_COMMAND_NAME.RATE: + // TODO: is this if even needed? + if (commandParser.args) { + window.gameController.game.frequency = commandParser.args; + } + + return true; + default: + return true; } + } + /** + * @for InputController + * @method processTransmitCommand + * @param commandParser {CommandParser} + * @return {boolean} + */ + processTransmitCommand(commandParser) { + // TODO: abstract the aircraft callsign matching let matches = 0; let match = -1; for (let i = 0; i < prop.aircraft.list.length; i++) { const aircraft = prop.aircraft.list[i]; - if (aircraft.matchCallsign(result.callsign)) { + if (aircraft.matchCallsign(commandParser.callsign)) { matches += 1; match = i; } @@ -807,17 +870,6 @@ export default class InputController { const aircraft = prop.aircraft.list[match]; - return aircraft.runCommands(result.args); - } - - /** - * Encapsulation of repeated boolean logic - * - * @for InputController - * @method _isArrowControlMethod - * @return {boolean} - */ - _isArrowControlMethod() { - return window.gameController.game.option.get(GAME_OPTION_NAMES.CONTROL_METHOD) === 'arrows'; + return aircraft.runCommands(commandParser.args); } } diff --git a/src/assets/scripts/aircraft/AircraftInstanceModel.js b/src/assets/scripts/aircraft/AircraftInstanceModel.js index 07499160..bea829c8 100644 --- a/src/assets/scripts/aircraft/AircraftInstanceModel.js +++ b/src/assets/scripts/aircraft/AircraftInstanceModel.js @@ -3,7 +3,9 @@ import $ from 'jquery'; import _forEach from 'lodash/forEach'; import _get from 'lodash/get'; import _has from 'lodash/has'; +import _isEqual from 'lodash/isEqual'; import _isNaN from 'lodash/isNaN'; +import _isNil from 'lodash/isNil'; import _isString from 'lodash/isString'; import _map from 'lodash/map'; import AircraftFlightManagementSystem from './FlightManagementSystem/AircraftFlightManagementSystem'; @@ -340,6 +342,8 @@ export default class Aircraft { // Update the assigned SID to use the portion for the new runway const leg = this.fms.currentLeg; + // TODO: this should return early + // TODO: use existing enumeration for `sid` if (leg.type === 'sid') { const a = _map(leg.waypoints, (v) => v.altitude); const cvs = !a.every((v) => v === window.airportController.airport_get().initial_alt); @@ -493,17 +497,15 @@ export default class Aircraft { * @method matchCallsign * @param callsign {string} */ - matchCallsign(callsign) { - if (callsign === '*') { + matchCallsign(callsignToMatch) { + if (callsignToMatch === '*') { return true; } - callsign = callsign.toLowerCase(); - const this_callsign = this.getCallsign().toLowerCase(); - - return this_callsign.indexOf(callsign) === 0; + return _isEqual(callsignToMatch.toUpperCase(), this.getCallsign()); } + // TODO: this could be a getter /** * @for AircraftInstanceModel * @method getCallsign @@ -513,6 +515,7 @@ export default class Aircraft { return (this.getAirline().icao + this.callsign).toUpperCase(); } + // TODO: this could be a getter /** * @for AircraftInstanceModel * @method getAirline @@ -713,8 +716,7 @@ export default class Aircraft { return ['fail', 'not understood']; } - - return this[call_func].apply(this, [data]); + return this[call_func](data); } /** @@ -727,10 +729,10 @@ export default class Aircraft { const direction = data[0]; let heading = data[1]; const incremental = data[2]; - let instruction = null; let amount = 0; + let instruction; - if (isNaN(heading)) { + if (_isNaN(heading)) { return ['fail', 'heading not understood']; } @@ -754,10 +756,8 @@ export default class Aircraft { this.cancelLanding(); } - // TODO: improve these if blocks. ['heading'].indexOf(wp.navmode) should be simplified to _has() - // or something similiar. indexOf is confusing here. // already being vectored or holding. Will now just change the assigned heading. - if (['heading'].indexOf(wp.navmode) > -1) { + if (wp.navmode === WAYPOINT_NAV_MODE.HEADING) { this.fms.setCurrent({ altitude: wp.altitude, navmode: WAYPOINT_NAV_MODE.HEADING, @@ -766,10 +766,10 @@ export default class Aircraft { turn: direction, hold: false }); - } else if (['hold'].indexOf(wp.navmode) > -1) { + } else if (wp.navmode === WAYPOINT_NAV_MODE.HOLD) { // in hold. Should leave the hold, and add leg for vectors const index = this.fms.current[0] + 1; - const waypointLeg = new Waypoint( + const waypointToAdd = new Waypoint( { altitude: wp.altitude, navmode: WAYPOINT_NAV_MODE.HEADING, @@ -784,13 +784,13 @@ export default class Aircraft { // add new Leg after hold leg this.fms.insertLeg({ firstIndex: index, - waypoints: [waypointLeg] + waypoints: [waypointToAdd] }); // move from hold leg to vector leg. this.fms.nextWaypoint(); } else if (f.sid || f.star || f.awy) { - const waypointLeg = new Waypoint( + const waypointToAdd = new Waypoint( { altitude: wp.altitude, navmode: WAYPOINT_NAV_MODE.HEADING, @@ -802,13 +802,13 @@ export default class Aircraft { airport ); - // TODO: this should be an FMS class method that accepts a new `waypointLeg` + // TODO: this should be an FMS class method that accepts a new `waypointToAdd` // insert wp with heading at current position within the already active leg - leg.waypoints.splice(this.fms.current[1], 0, waypointLeg); + leg.waypoints.splice(this.fms.current[1], 0, waypointToAdd); } else if (leg.route !== '[radar vectors]') { // needs new leg added if (this.fms.atLastWaypoint()) { - const waypointLeg = new Waypoint( + const waypointToAdd = new Waypoint( { altitude: wp.altitude, navmode: WAYPOINT_NAV_MODE.HEADING, @@ -821,12 +821,12 @@ export default class Aircraft { ); this.fms.appendLeg({ - waypoints: [waypointLeg] + waypoints: [waypointToAdd] }); this.fms.nextLeg(); } else { - const waypointLeg = new Waypoint( + const waypointToAdd = new Waypoint( { altitude: wp.altitude, navmode: WAYPOINT_NAV_MODE.HEADING, @@ -839,7 +839,7 @@ export default class Aircraft { ); this.fms.insertLegHere({ - waypoints: [waypointLeg] + waypoints: [waypointToAdd] }); } } @@ -847,19 +847,18 @@ export default class Aircraft { wp = this.fms.currentWaypoint; // update 'wp' // Construct the readback + instruction = 'fly heading'; if (direction) { instruction = `turn ${direction} heading`; - } else { - instruction = 'fly heading '; } const readback = {}; + readback.log = `${instruction} ${heading_to_string(wp.heading)}`; + readback.say = `${instruction} ${radio_heading(heading_to_string(wp.heading))}`; + if (incremental) { readback.log = `turn ${amount} degrees ${direction}`; readback.say = `turn ${groupNumbers(amount)} degrees ${direction}`; - } else { - readback.log = `${instruction} ${heading_to_string(wp.heading)}`; - readback.say = `${instruction} ${radio_heading(heading_to_string(wp.heading))}`; } return ['ok', readback]; @@ -873,16 +872,16 @@ export default class Aircraft { runAltitude(data) { const altitude = data[0]; let expedite = data[1]; + const airport = window.airportController.airport_get(); + const radioTrendAltitude = radio_trend('altitude', this.altitude, this.fms.altitudeForCurrentWaypoint()); + const currentWaypointRadioAltitude = radio_altitude(this.fms.altitudeForCurrentWaypoint()); if ((altitude == null) || isNaN(altitude)) { + // FIXME: move this to it's own command. if expedite can be passed as a sole command it should be its own command if (expedite) { this.fms.setCurrent({ expedite: true }); - return [ - 'ok', - // TODO: add FMSclass method for current waypoint altitude - `${radio_trend('altitude', this.altitude, this.fms.altitudeForCurrentWaypoint())} ${this.fms.altitudeForCurrentWaypoint()} expedite` - ]; + return ['ok', `${radioTrendAltitude} ${this.fms.altitudeForCurrentWaypoint()} expedite`]; } return ['fail', 'altitude not understood']; @@ -892,28 +891,25 @@ export default class Aircraft { this.cancelLanding(); } - - let ceiling = window.airportController.airport_get().ctr_ceiling; + let ceiling = airport.ctr_ceiling; if (window.gameController.game.option.get('softCeiling') === 'yes') { ceiling += 1000; } this.fms.setAll({ // TODO: enumerate the magic numbers - altitude: clamp(round(window.airportController.airport_get().elevation / 100) * 100 + 1000, altitude, ceiling), + altitude: clamp(round(airport.elevation / 100) * 100 + 1000, altitude, ceiling), expedite: expedite }); - // TODO: this seems like a strange reassignment. perhaps this should be renamed or commented as to why. + let isExpeditingString = ''; if (expedite) { - expedite = ' and expedite'; - } else { - expedite = ''; + isExpeditingString = 'and expedite'; } const readback = { - log: `${radio_trend('altitude', this.altitude, this.fms.altitudeForCurrentWaypoint())} ${this.fms.altitudeForCurrentWaypoint()} ${expedite}`, - say: `${radio_trend('altitude', this.altitude, this.fms.altitudeForCurrentWaypoint())} ${radio_altitude(this.fms.altitudeForCurrentWaypoint())} ${expedite}` + log: `${radioTrendAltitude} ${this.fms.altitudeForCurrentWaypoint()} ${isExpeditingString}`, + say: `${radioTrendAltitude} ${currentWaypointRadioAltitude} ${isExpeditingString}` }; return ['ok', readback]; @@ -925,9 +921,7 @@ export default class Aircraft { * @return {array} */ runClearedAsFiled() { - // TODO: the `runSID` method does not always return a boolean, in some cases it returns readbacks - // which look to never be used? - if (!this.runSID()) { + if (!this.runSID([this.destination])) { return [true, 'unable to clear as filed']; } @@ -1003,17 +997,13 @@ export default class Aircraft { return ['fail', 'speed not understood']; } - this.fms.setAll({ - speed: clamp( - this.model.speed.min, - speed, - this.model.speed.max - ) - }); + const clampedSpeed = clamp(this.model.speed.min, speed, this.model.speed.max); + this.fms.setAll({ speed: clampedSpeed }); + const radioTrendSpeed = radio_trend('speed', this.speed, this.fms.currentWaypoint.speed); const readback = { - log: `${radio_trend('speed', this.speed, this.fms.currentWaypoint.speed)} ${this.fms.currentWaypoint.speed}`, - say: `${radio_trend('speed', this.speed, this.fms.currentWaypoint.speed)} ${radio_spellOut(this.fms.currentWaypoint.speed)}` + log: `${radioTrendSpeed} ${this.fms.currentWaypoint.speed}`, + say: `${radioTrendSpeed} ${radio_spellOut(this.fms.currentWaypoint.speed)}` }; return ['ok', readback]; @@ -1033,18 +1023,21 @@ export default class Aircraft { let inboundHdg; // let inboundDir; + // TODO: this might be better handled from within the parser if (dirTurns == null) { // standard for holding patterns is right-turns dirTurns = 'right'; } + // TODO: this might be better handled from within the parser if (legLength == null) { legLength = '1min'; } + // TODO: simplify this nested if. if (holdFix !== null) { holdFix = holdFix.toUpperCase(); - holdFixLocation = window.airportController.airport_get().getFixPosition(holdFix); + holdFixLocation = airport.getFixPosition(holdFix); if (!holdFixLocation) { return ['fail', `unable to find fix ${holdFix}`]; @@ -1059,6 +1052,7 @@ export default class Aircraft { if (holdFix) { // holding over a specific fix (currently only able to do so on inbound course) inboundHdg = vradial(vsub(this.position, holdFixLocation)); + if (holdFix !== this.fms.currentWaypoint.fix) { // not yet headed to the hold fix this.fms.insertLegHere({ @@ -1164,6 +1158,7 @@ export default class Aircraft { */ runDirect(data) { const fixname = data[0].toUpperCase(); + // TODO replace with FixCollection const fix = window.airportController.airport_get().getFixPosition(fixname); if (!fix) { @@ -1183,8 +1178,10 @@ export default class Aircraft { runFix(data) { let last_fix; let fail; - const fixes = _map(data[0], (fixname) => { + const fixes = _map(data, (fixname) => { + // TODO: this may beed to be the FixCollection const fix = window.airportController.airport_get().getFixPosition(fixname); + if (!fix) { fail = ['fail', `unable to find fix called ${fixname}`]; @@ -1205,7 +1202,8 @@ export default class Aircraft { return fail; } - for (let i = fixes.length - 1; i >= 0; i--) { + for (let i = 0; i < fixes.length; i++) { + // FIXME: use enumerated constant for type this.fms.insertLegHere({ type: 'fix', route: fixes[i] }); } @@ -1248,41 +1246,40 @@ export default class Aircraft { * @for AircraftInstanceModel * @method runSID */ - runSID() { + runSID(data) { const airport = window.airportController.airport_get(); const { sidCollection } = airport; + const sidId = data[0]; + const standardRouteModel = sidCollection.findRouteByIcao(sidId); + const exit = airport.getSIDExitPoint(sidId); + // TODO: perhaps this should use the `RouteModel`? + const route = `${airport.icao}.${sidId}.${exit}`; - if (!airport.sidCollection.hasRoute(this.destination)) { + if (_isNil(standardRouteModel)) { return ['fail', 'SID name not understood']; } - const standardRouteModel = sidCollection.findRouteByIcao(this.destination); - const exitFixName = airport.getSIDExitPoint(this.destination); - const route = `${airport.icao.toUpperCase()}.${this.destination}.${exitFixName}`; - if (this.category !== FLIGHT_CATEGORY.DEPARTURE) { return ['fail', 'unable to fly SID, we are an inbound']; } if (!this.rwy_dep) { - this.setDepartureRunway(airport.runway); + this.setDepartureRunway(airportController.airport_get().runway); } if (!standardRouteModel.hasFixName(this.rwy_dep)) { return ['fail', `unable, the ${standardRouteModel.name} departure not valid from Runway ${this.rwy_dep}`]; } - this.fms.followSID(route); + // TODO: this is the wrong place for this `.toUpperCase()` + this.fms.followSID(route.toUpperCase()); - // TODO: casing may be an issue here. const readback = { - log: `cleared to destination via the ${this.destination} departure, then as filed`, + log: `cleared to destination via the ${sidId} departure, then as filed`, say: `cleared to destination via the ${standardRouteModel.name} departure, then as filed` }; - // TODO: this return format is never used by the calling method. the calling method expects a boolean - // return ['ok', readback]; - return true; + return ['ok', readback]; } /** @@ -1299,6 +1296,8 @@ export default class Aircraft { return ['fail', 'unable to fly STAR, we are a departure!']; } + // TODO: the data[0].length check might not be needed. this is covered via the CommandParser when + // this method runs as the result of a command. if (data[0].length === 0 || !airport.starCollection.hasRoute(routeModel.procedure)) { return ['fail', 'STAR name not understood']; } @@ -1422,6 +1421,7 @@ export default class Aircraft { * @param data */ runTaxi(data) { + // TODO: all this if logic should be simplified or abstracted if (this.category !== FLIGHT_CATEGORY.DEPARTURE) { return ['fail', 'inbound']; } @@ -1468,6 +1468,7 @@ export default class Aircraft { * @param data */ runTakeoff(data) { + // TODO: all this if logic should be simplified or abstracted if (this.category !== 'departure') { return ['fail', 'inbound']; } @@ -1543,6 +1544,7 @@ export default class Aircraft { * @param data */ runAbort(data) { + // TODO: these ifs on `mode` should be converted to a switch if (this.mode === FLIGHT_MODES.TAXI) { this.mode = FLIGHT_MODES.APRON; this.taxi_start = 0; @@ -1557,6 +1559,7 @@ export default class Aircraft { return ['fail', 'unable to return to the terminal']; } else if (this.mode === FLIGHT_MODES.LANDING) { this.cancelLanding(); + const readback = { log: `go around, fly present heading, maintain ${this.fms.altitudeForCurrentWaypoint()}`, say: `go around, fly present heading, maintain ${radio_altitude(this.fms.altitudeForCurrentWaypoint())}` @@ -1586,6 +1589,7 @@ export default class Aircraft { return ['fail', 'unable to abort']; } + // FIXME: is this in use? /** * @for AircraftInstanceModel * @method runDebug @@ -1595,6 +1599,7 @@ export default class Aircraft { return ['ok', { log: 'in the console, look at the variable ‘aircraft’', say: '' }]; } + // FIXME: is this in use? /** * @for AircraftInstanceModel * @method runDelete @@ -1662,6 +1667,7 @@ export default class Aircraft { return false; } + // FIXME: is this method still in use? /** * @for AircraftInstanceModel * @method pushHistory @@ -1719,17 +1725,17 @@ export default class Aircraft { } /** - * Aircraft is actively following an instrument approach + * Aircraft is actively following an instrument approach and is elegible for reduced separation + * + * If the game ever distinguishes between ILS/MLS/LAAS + * approaches and visual/localizer/VOR/etc. this should + * distinguish between them. Until then, presume landing is via + * ILS with appropriate procedures in place. + * * @for AircraftInstanceModel * @method runTakeoff */ isPrecisionGuided() { - // Whether this aircraft is elegible for reduced separation - // - // If the game ever distinguishes between ILS/MLS/LAAS - // approaches and visual/localizer/VOR/etc. this should - // distinguish between them. Until then, presume landing is via - // ILS with appropriate procedures in place. return this.mode === FLIGHT_MODES.LANDING; } diff --git a/src/assets/scripts/aircraft/FlightManagementSystem/AircraftFlightManagementSystem.js b/src/assets/scripts/aircraft/FlightManagementSystem/AircraftFlightManagementSystem.js index 9ebb15b9..1fa72fa7 100644 --- a/src/assets/scripts/aircraft/FlightManagementSystem/AircraftFlightManagementSystem.js +++ b/src/assets/scripts/aircraft/FlightManagementSystem/AircraftFlightManagementSystem.js @@ -364,6 +364,7 @@ export default class AircraftFlightManagementSystem { */ setCurrent(data) { // TODO: refactor this, what is actually happening here? + // FIXME: it may be easier to replace current waypoint with a new one? for (const i in data) { this.currentWaypoint[i] = data[i]; } @@ -510,9 +511,11 @@ export default class AircraftFlightManagementSystem { * Inserts the SID as the first Leg in the fms's flightplan */ followSID(route) { + const airport = window.airportController.airport_get(); + for (let i = 0; i < this.legs.length; i++) { // sid assigned after taking off without SID - if (this.legs[i].route === window.airportController.airport_get().icao) { + if (this.legs[i].route === airport.icao) { // remove the manual departure leg this.legs.splice(i, 1); } else if (this.legs[i].type === FP_LEG_TYPE.SID) { @@ -529,7 +532,7 @@ export default class AircraftFlightManagementSystem { }); this.setAll({ - altitude: Math.max(window.airportController.airport_get().initial_alt, this.my_aircraft.altitude) + altitude: Math.max(airport.initial_alt, this.my_aircraft.altitude) }); } diff --git a/src/assets/scripts/airport/AirspaceModel.js b/src/assets/scripts/airport/AirspaceModel.js index 668884c2..8ce26097 100644 --- a/src/assets/scripts/airport/AirspaceModel.js +++ b/src/assets/scripts/airport/AirspaceModel.js @@ -3,17 +3,7 @@ import _isNumber from 'lodash/isNumber'; import _map from 'lodash/map'; import BaseModel from '../base/BaseModel'; import PositionModel from '../base/PositionModel'; - -/** - * Utility function to convert a number to thousands. - * - * Given a flightlevel FL180, this function outs puts 18,000 - * - * @function covertToThousands - * @param {number} value - * @return {number} - */ -const convertToThousands = (value) => parseInt(value, 10) * 100; +import { convertToThousands } from '../utilities/unitConverters'; /** * An enclosed region defined by a series of Position objects and an altitude range diff --git a/src/assets/scripts/commandParser/CommandModel.js b/src/assets/scripts/commandParser/CommandModel.js new file mode 100644 index 00000000..c825cc10 --- /dev/null +++ b/src/assets/scripts/commandParser/CommandModel.js @@ -0,0 +1,101 @@ +import { COMMAND_DEFINITION } from './commandDefinitions'; + +/** + * A definition of a specific command and it's arguments. + * + * Conatins a command name, which maps 1:1 with a name defined in `commandMap.js` and `commandDefinitions.js`. + * Commands may have an alias or many, we care only about the root command. The command map will map any + * alias to a root command and this `CommandModel` is only concerned about those root commands. It has + * no way of knowing what the original alias was, if one was used. + * + * Each `CommandModel` will be expected to have, at a minimum, a `name` and a matching `COMMAND_DEFINITION`. + * + * @class CommandModel + */ +export default class CommandModel { + /** + * @constructor + * @for CommandModel + */ + constructor(name = '') { + /** + * command name, should match a command in the COMMANDS constant + * + * @property name + * @type {string} + */ + this.name = name; + + /** + * A reference to the COMMAND_DEFINITION for this particular command. + * this gives us access to both the `validate` and `parse` methods + * that belong to this command. + * + * Storing this as a class property allows us to do the lookup once + * and then make it available to the rest of the class so it can + * be referenced when needed. + * + * @property _commandDefinition + * @type {object} + * @private + */ + this._commandDefinition = COMMAND_DEFINITION[name]; + + /** + * list of command arguments + * + * - assumed to be the text command names + * - may be empty, depending on the command + * - should only ever be strings on initial set immediately after instantiation + * - will later be parsed via the `_commandDefinition.parse()` method to the + * correct data types and shape + * + * @property args + * @type {array} + * @default [] + */ + this.args = []; + + // TODO: may need to throw here if `_commandDefinition` is undefined + } + + /** + * Return an array of [name, ...args] + * + * We use this shape solely to match the existing api. + * + * @property nameAndArgs + * @return {array} + */ + get nameAndArgs() { + return [ + this.name, + ...this.args + ]; + } + + /** + * Send the initial args off to the validator + * + * @for CommandModel + * @method validateArgs + * @return {string|undefined} + */ + validateArgs() { + return this._commandDefinition.validate(this.args); + } + + /** + * Send the initial args, set from the `CommandParser` right after instantiation, off to + * the parser for formatting. + * + * @for CommandModel + * @method parseArgs + */ + parseArgs() { + // this completely overwrites current args. this is intended because all args are received as + // strings but consumed as strings, numbers or booleans. and when the args are initially set + // they may not all be available yet + this.args = this._commandDefinition.parse(this.args); + } +} diff --git a/src/assets/scripts/commandParser/CommandParser.js b/src/assets/scripts/commandParser/CommandParser.js new file mode 100644 index 00000000..175c24cf --- /dev/null +++ b/src/assets/scripts/commandParser/CommandParser.js @@ -0,0 +1,317 @@ +import _compact from 'lodash/compact'; +import _forEach from 'lodash/forEach'; +import _has from 'lodash/has'; +import _isString from 'lodash/isString'; +import _map from 'lodash/map'; +import _tail from 'lodash/tail'; +import CommandModel from './CommandModel'; +import { unicodeToString } from '../utilities/generalUtilities'; +import { + SYSTEM_COMMANDS, + COMMAND_MAP +} from './commandMap'; +import { REGEX } from '../constants/globalConstants'; + +/** + * Symbol used to split the command string as it enters the class. + * + * @property COMMAND_ARGS_SEPARATOR + * @type {string} + * @final + */ +const COMMAND_ARGS_SEPARATOR = ' '; + +/** + * This class is responsible for taking the content of the `$commandInput` and parsing it + * out into commands and arguments. + * + * Everything this class needs comes in as a single string provided by `InputController.input_run()`. + * ex: + * - `timewarp 50` + * - `AA777 fh 0270 d 050 sp 200` + * - `AA777 hold dumba left 2min` + * + * **Differentiation of commands and arguments is determinied by splitting the string on an empty space. This + * is very important, so legacy commands did not have spaces between the command and argument. With this + * implementation _every_ command shall have a space between itself and it's arguments.** + * + * Commands are broken out into two categories: `System` and `Transmit`. + * - System commands are zero or single argument commands that are used for interacting with the app + * itslef. Things like `timewarp` or `tutorial` are examples of system commands. + * + * - Transmit commands are instructions meant for a specific aircraft within the controlled airspace. + * These commands can have zero to many arguments, depending on the command. Some examples of transmit + * commands are `to`, `taxi`, `hold`. + * + * Commands go through a lifecycle as they move from raw to parsed: + * - user types command and presses enter + * - command string is captured via input value, then passed as an argument to this class + * - determine if command string is a `System Command` or `Transmit` + * - creation of `CommandModel` objects for each command/argment group found + * - validate command arguments (number of arguments and data type) + * - parse command arguments + * + * All available commands are defined in the `commandMap`. Two terms of note are alias and root command. + * We would call the `takeoff` command a root command and `to` and `cto` alises. The root command is the + * one that shares the same key as the command definition which gives us the correct validator and parser. + * The root command is also what the `AircraftInstanceModel` is expecting when it receives commands + * from the `InputController`. + * + * @class CommandParser + */ +export default class CommandParser { + /** + * @constructor + * @for CommandParser + * @param rawCommandWithArgs {string} string present in the `$commandInput` when the user pressed `enter` + */ + constructor(rawCommandWithArgs = '') { + if (!_isString(rawCommandWithArgs)) { + // istanbul ignore next + // eslint-disable-next-line max-len + throw new TypeError(`Invalid parameter. CommandParser expects a string but received ${typeof rawCommandWithArgs}`); + } + + /** + * Command name + * + * Could be either Transmit or a System command + * + * This is consumed by the `InputController` after parsing here and is used to + * determine what to do with the parsed command(s) + * + * @type {string} + * @default '' + */ + this.command = ''; + + /** + * Aircraft callsign + * + * this is optional and not included with system commands + * + * @type {string} + * @default '' + */ + this.callsign = ''; + + /** + * List of `CommandModel` objects. + * + * Each command is contained within a `CommandModel`, even System commands. This provides + * a consistent interface for obtaining commands and arguments (via getter) and also + * aloows for easy implementation of the legacy API structure. + * + * @type {array} + */ + this.commandList = []; + + this._extractCommandsAndArgs(rawCommandWithArgs.toLowerCase()); + } + + /** + * Return an array of [commandName, ...args] + * + * We use this shape solely to match the existing api. + * + * When command is a System command: + * - commandList is assumed to have a length on 1 + * - commandList[0].args[0] is assumed to have a single string value + * + * @property args + * @return {string|array} + */ + get args() { + if (this.command !== SYSTEM_COMMANDS.transmit) { + return this.commandList[0].args; + } + + return _map(this.commandList, (command) => command.nameAndArgs); + } + + /** + * Accept the entire string provided to the constructor and attempt to break it up into: + * - System command and its arguments + * - Transmit commands and thier arguments + * + * @for CommandParser + * @method _extractCommandsAndArgs + * @param rawCommandWithArgs {string} + * @private + */ + _extractCommandsAndArgs(rawCommandWithArgs) { + const commandOrCallsignIndex = 0; + const commandArgSegmentsWithCallsign = rawCommandWithArgs.split(COMMAND_ARGS_SEPARATOR); + const callsignOrSystemCommandName = commandArgSegmentsWithCallsign[commandOrCallsignIndex]; + // effectively a slice of the array that returns everything but the first item + const commandArgSegments = _tail(commandArgSegmentsWithCallsign); + + if (this._isSystemCommand(callsignOrSystemCommandName)) { + this._buildSystemCommandModel(commandArgSegmentsWithCallsign); + + return; + } + + this._buildTransmitCommandModels(callsignOrSystemCommandName, commandArgSegments); + } + + /** + * Build a `CommandModel` for a System command then add that model to the `commandList` + * + * @for CommandParser + * @method _buildSystemCommandModel + * @private + */ + _buildSystemCommandModel(commandArgSegments) { + const commandIndex = 0; + const argIndex = 1; + const commandName = commandArgSegments[commandIndex]; + const commandModel = new CommandModel(commandName); + commandModel.args.push(commandArgSegments[argIndex]); + + this.command = commandName; + this.commandList.push(commandModel); + + this._validateAndParseCommandArguments(); + } + + /** + * Build `CommandModel` objects for each transmit commands then add them to the `commandList` + * + * @private + */ + _buildTransmitCommandModels(callsignOrSystemCommandName, commandArgSegments) { + this.command = SYSTEM_COMMANDS.transmit; + this.callsign = callsignOrSystemCommandName; + this.commandList = this._buildCommandList(commandArgSegments); + + this._validateAndParseCommandArguments(); + } + + /** + * Loop through the commandArgSegments array and either create a new `CommandModel` or add + * arguments to a `CommandModel`. + * + * commandArgSegments will contain both commands and arguments (very contrived example): + * - `[cmd, arg, arg, cmd, cmd, arg, arg, arg]` + * + * this method is expecting that + * the first item it receives, that is not a space, is a command. we then push each successive + * array item to the args array until we find another command. then we repeat the process. + * + * this allows us to create several `CommandModel` with arguments and only loop over them once. + * + * @for CommandParser + * @method _buildCommandList + * @param commandArgSegments {array} + * @return {array} + * @private + */ + _buildCommandList(commandArgSegments) { + let commandModel; + + // TODO: this still feels icky and could be simplified some more + const commandList = _map(commandArgSegments, (commandOrArg) => { + if (commandOrArg === '') { + return; + } else if (REGEX.UNICODE.test(commandOrArg)) { + const commandString = unicodeToString(commandOrArg); + commandModel = new CommandModel(COMMAND_MAP[commandString]); + + return commandModel; + } else if (_has(COMMAND_MAP, commandOrArg) && !this._isAliasCommandAnArg(commandModel, commandOrArg)) { + commandModel = new CommandModel(COMMAND_MAP[commandOrArg]); + + return commandModel; + } else if (typeof commandModel === 'undefined') { + // if we've made it here and commandModel is still undefined, a command was not found + return; + } + + commandModel.args.push(commandOrArg); + }); + + + return _compact(commandList); + } + + /** + * This method is used for addressing a very specific situation + * + * When the current command is `heading` and one of the arguments is `l`, the parser interprets + * the `l` as another command. `l` is an alias for the `land` command. + * + * This method expects that a commandString will look like: + * `AA321 t l 042` + * + * We look for the `heading` command and no existing arguments, as the `l` would become the + * first argument in this situation. + * + * @for CommandParser + * @method _isAliasCommandAnArg + * @param commandModel {CommandModel} + * @param commandOrArg {string} + * @return {boolean} + */ + _isAliasCommandAnArg(commandModel, commandOrArg) { + if (!commandModel) { + return false; + } + + return commandModel.name === 'heading' && commandModel.args.length === 0 && commandOrArg === 'l'; + } + + /** + * Fire off the `_validateCommandArguments` method and throws any errors returned + * + * @for CommandParser + * @method _validateAndParseCommandArguments + * @private + */ + _validateAndParseCommandArguments() { + const validationErrors = this._validateCommandArguments(); + + if (validationErrors.length > 0) { + _forEach(validationErrors, (error) => { + throw error; + }); + } + } + + /** + * For each `CommandModel` in the `commandList`, first validate it's arguments + * then parse those arguments into a consumable array. + * + * @for CommandParser + * @method _validateCommandArguments + * @private + */ + _validateCommandArguments() { + return _compact(_map(this.commandList, (command) => { + const hasError = command.validateArgs(); + + if (hasError) { + // we only return here so all the errors can be thrown at once + // from within the calling method + return hasError; + } + + command.parseArgs(); + })); + } + + /** + * Encapsulation of boolean logic used to determine if the `callsignOrSystemCommandName` + * is in fact a system command. + * + * + * @for CommandParser + * @method _isSystemCommand + * @param callsignOrSystemCommandName {string} + * @return {boolean} + */ + _isSystemCommand(callsignOrSystemCommandName) { + return _has(SYSTEM_COMMANDS, callsignOrSystemCommandName) && + callsignOrSystemCommandName !== SYSTEM_COMMANDS.transmit; + } +} diff --git a/src/assets/scripts/commandParser/argumentParsers.js b/src/assets/scripts/commandParser/argumentParsers.js new file mode 100644 index 00000000..4518aa54 --- /dev/null +++ b/src/assets/scripts/commandParser/argumentParsers.js @@ -0,0 +1,167 @@ +import { isValidDirectionString } from './argumentValidators'; +import { + convertToThousands, + convertStringToNumber +} from '../utilities/unitConverters'; + +/** + * Enumeration of possible the hold command argument names. + * + * Enumerated here base these nanes are shared accross several functions and this + * provides a single source of truth. + * + * @property HOLD_COMMAND_ARG_NAMES + * @type {Object} + * @final + */ +const HOLD_COMMAND_ARG_NAMES = { + TURN_DIRECTION: 'turnDirection', + LEG_LENGTH: 'legLength', + FIX_NAME: 'fixName' +}; + +/** + * Converts a flight level altitude to a number in thousands and converts second arg to a boolean + * + * @function altitudeParser + * @param args {array} + * @return {array} + */ +export const altitudeParser = (args) => { + const altitude = convertToThousands(args[0]); + // the validator will have already caught an invalid value here. if one exists, it is assumed to be valid and + // thus we return true. otherwise its false + const shouldExpedite = typeof args[1] !== 'undefined'; + + return [altitude, shouldExpedite]; +}; + +/** + * Accepts a direction string: + * - `left / l / right / r` + * + * and returns `left / right` + * + * @function directionNormalizer + * @param direction {string} + * @return normalizedDirection {string} + */ +const directionNormalizer = (direction) => { + let normalizedDirection = direction; + + if (direction === 'l') { + normalizedDirection = 'left'; + } else if (direction === 'r') { + normalizedDirection = 'right'; + } + + return normalizedDirection; +}; + +/** + * Returns a consistent array with the same shape no matter the number of arguments received + * + * Converts a flight level altitude to a number in thousands and converts second arg to a boolean + * + * @function headingParser + * @param args {array} + * @return {array} + */ +export const headingParser = (args) => { + let direction; + let heading; + let isIncremental = false; + + switch (args.length) { + case 1: + // existing api is expeting undefined values to be exactly null + direction = null; + heading = convertStringToNumber(args[0]); + + return [direction, heading, isIncremental]; + case 2: + isIncremental = args[1].length === 2; + direction = directionNormalizer(args[0]); + heading = convertStringToNumber(args[1]); + + return [direction, heading, isIncremental]; + default: + throw new Error('An error ocurred parsing the Heading arguments'); + } +}; + +/** + * Abstracted boolean logic used to detmine if a string contains `min` or `nm`. + * + * This is useful specifically with the `findHoldCommandByType`. + * + * @function isLegLengthArg + * @param arg {string} + * @return {boolean} + */ +const isLegLengthArg = (arg) => arg.indexOf('min') !== -1 || arg.indexOf('nm') !== -1; + +/** + * Given a type and an argument list, find the first occurance of `type` from within the argument list. + * + * We are looking for one of three things here: + * - `turnDirection` - a variation of left or right + * - `legLength` - length of hold leg in either minutes (min) or nautical miles (nm) + * - `fixName` - assumed to be a string that isn't a `turnDirection` or `legLength`. The parser has no way of + * knowing if a certain string is an actual `fixName`. We can only determine that it isn't a + * `turnDirection` or `legLength`. This will error from within the `runHold` method if the + * `fixName` is not valid. + * + * @function findHoldCommandByType + * @param type {HOLD_COMMAND_ARG_NAMES} + * @param args {array} + * @return {string|null} + */ +export const findHoldCommandByType = (type, args) => { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + switch (type) { + case HOLD_COMMAND_ARG_NAMES.TURN_DIRECTION: + if (!isValidDirectionString(arg)) { + continue; + } + + return directionNormalizer(arg); + case HOLD_COMMAND_ARG_NAMES.LEG_LENGTH: + if (!isLegLengthArg(arg)) { + continue; + } + + return arg; + case HOLD_COMMAND_ARG_NAMES.FIX_NAME: + if (isValidDirectionString(arg) || isLegLengthArg(arg)) { + continue; + } + + return arg; + default: + return null; + } + } + + return null; +}; + +/** + * The `hold` command accepts arguments in any order thus, we use the `findHoldCommandByType` helper + * method to do that for us. This provides an easy way tp find the correct argument, no matter the order, + * and consistently return an array of the same shape. + * + * @function holdParser + * @param args {array} + * @return {array} + */ +export const holdParser = (args) => { + // existing api is expeting undefined values to be exactly null + const fixName = findHoldCommandByType(HOLD_COMMAND_ARG_NAMES.FIX_NAME, args); + const turnDirection = findHoldCommandByType(HOLD_COMMAND_ARG_NAMES.TURN_DIRECTION, args); + const legLength = findHoldCommandByType(HOLD_COMMAND_ARG_NAMES.LEG_LENGTH, args); + + return [turnDirection, legLength, fixName]; +}; diff --git a/src/assets/scripts/commandParser/argumentValidators.js b/src/assets/scripts/commandParser/argumentValidators.js new file mode 100644 index 00000000..3c995f4e --- /dev/null +++ b/src/assets/scripts/commandParser/argumentValidators.js @@ -0,0 +1,230 @@ +import _isNaN from 'lodash/isNaN'; +import _isString from 'lodash/isString'; +import _forEach from 'lodash/forEach'; +import { convertStringToNumber } from '../utilities/unitConverters'; +import { EXPEDITE } from './commandMap'; +import { ERROR_MESSAGE } from './commandParserMessages'; + +/** + * Check that `args` has exactly zero values + * + * @function zeroArgumentsValidator + * @param args {array} + * @return {string|undefined} + */ +export const zeroArgumentsValidator = (args = []) => { + if (args.length !== 0) { + return ERROR_MESSAGE.ZERO_ARG_LENGTH; + } +}; + +/** + * Checks that `args` has exactly one value + * + * @function singleArgumentValidator + * @param args {array} + * @return {string|undefined} + */ +export const singleArgumentValidator = (args = []) => { + if (args.length !== 1) { + return ERROR_MESSAGE.SINGLE_ARG_LENGTH; + } +}; + +/** + * Checks that `args` has exactly zero or one value + * + * @function zeroOrOneArgumentValidator + * @param args {array} + * @return {string|undefined} + */ +export const zeroOrOneArgumentValidator = (args = []) => { + if (args.length > 1) { + return ERROR_MESSAGE.ZERO_OR_ONE_ARG_LENGTH; + } +}; + +/** + * Checks that `args` has exactly one or two values + * + * @function oneOrTwoArgumentValidator + * @param args {array} + * @return {string|undefined} + */ +export const oneOrTwoArgumentValidator = (args = []) => { + if (args.length < 1 || args.length > 2) { + return ERROR_MESSAGE.ONE_OR_TWO_ARG_LENGTH; + } +}; + +/** + * Checks that `args` has exactly one, two or three values + * + * @function oneToThreeArgumentsValidator + * @param args {array} + * @return {string|undefined} + */ +export const oneToThreeArgumentsValidator = (args = []) => { + if (args.length === 0 || args.length > 3) { + return ERROR_MESSAGE.ONE_TO_THREE_ARG_LENGTH; + } +}; + +/** + * Checks that `args` has exactly one or three values + * + * @function oneOrThreeArgumentsValidator + * @param args {array} + * @return {string|undefined} + */ +export const oneOrThreeArgumentsValidator = (args = []) => { + if (args.length !== 1 && args.length !== 3) { + return ERROR_MESSAGE.ONE_OR_THREE_ARG_LENGTH; + } +}; + +/** + * Checks that args is the required length and the data is of the correct type + * + * ``` + * Allowed argument shapes: + * - ['030'] + * - ['030', 'expedite'] + * - ['030', 'x'] + * ``` + * + * @function altitudeValidator + * @param args {array} + * @return {string|undefined} + */ +export const altitudeValidator = (args = []) => { + const hasLengthError = oneOrTwoArgumentValidator(args); + + if (hasLengthError) { + return hasLengthError; + } + + if (args.length === 2 && EXPEDITE.indexOf(args[1]) === -1) { + return ERROR_MESSAGE.ALTITUDE_EXPEDITE_ARG; + } +}; + +/** + * Verifies a list of fix names are all strings and that there is at least one + * + * @function fixValidator + * @param args {array} + * @return {array} + */ +export const fixValidator = (args = []) => { + let hasTypeError; + + if (args.length < 1) { + return ERROR_MESSAGE.ONE_OR_MORE_ARG_LENGTH; + } + + _forEach(args, (arg) => { + if (!_isString(arg) && !hasTypeError) { + hasTypeError = ERROR_MESSAGE.MUST_BE_STRING; + } + }); + + if (hasTypeError) { + return hasTypeError; + } +}; + +/** + * Returns true if value is one of `left / l / right / r` + * + * @function isValidDirectionString + * @param value {string} + * @return {boolean} + */ +export const isValidDirectionString = (value) => { + return value === 'left' || + value === 'l' || + value === 'right' || + value === 'r'; +}; + +/** + * Checks that args is the required length and the data is of the correct type for the number of arguments + * + * ``` + * Allowed arguments shapes: + * - ['180'] + * - ['left', '180'] + * - ['l', '180'] + * - ['left', '80'] + * - ['l', '80'] + * ``` + * + * @function headingValidator + * @param args {array} + * @return {string|undefined} + */ +export const headingValidator = (args = []) => { + const length = args.length; + const hasLengthError = oneOrTwoArgumentValidator(args); + let numberFromString; + + if (hasLengthError) { + return hasLengthError; + } + + switch (length) { + case 1: + numberFromString = convertStringToNumber(args[0]); + + if (_isNaN(numberFromString)) { + return ERROR_MESSAGE.HEADING_MUST_BE_NUMBER; + } + + break; + case 2: + numberFromString = convertStringToNumber(args[1]); + + if (!isValidDirectionString(args[0])) { + return ERROR_MESSAGE.INVALID_DIRECTION_STRING; + } + + if (isNaN(numberFromString)) { + return ERROR_MESSAGE.HEADING_MUST_BE_NUMBER; + } + + break; + // default case is included only for semtantics, this should not ever be reachable + // istanbul ignore next + default: + throw new Error('An error ocurred parsing the Heading arguments'); + } +}; + +/** + * Checks that args is the required length and the data is of the correct type + * + * ``` + * Allowed argument shapes: + * - ['dumba'] + * - ['dumba', 'left', '2min'] + * - ['dumba', 'left', '2nm'] + * - ['dumba', 'right', '2min'] + * - ['dumba', 'right', '2nm'] + * ``` + * + * @function holdValidator + * @param args {array} + * @return {array} + */ +export const holdValidator = (args = []) => { + if (args.length > 3) { + return ERROR_MESSAGE.ZERO_TO_THREE_ARG_LENGTH; + } + + for (let i = 0; i < args.length; i++) { + if (!_isString(args[i])) { + return ERROR_MESSAGE.MUST_BE_STRING; + } + } +}; diff --git a/src/assets/scripts/commandParser/commandDefinitions.js b/src/assets/scripts/commandParser/commandDefinitions.js new file mode 100644 index 00000000..2c04bf00 --- /dev/null +++ b/src/assets/scripts/commandParser/commandDefinitions.js @@ -0,0 +1,230 @@ +/** + * Root commands defined in the `commandMap` have a matching definition defined here. This definition + * give us access to vaildate and parse functions. Some commands don't require either function and simply + * pass the arguments through via `noop`. Other commands commands have very unique demands for how + * arguments are formatted, these functions let us validate and parse on a case by case basis. + * + * Keys are lowercased here so they can be accessed programatically using input string segments + * that are converted to lowercase for ease of comparison. + * + * @fileoverview + */ +import { + convertToThousands, + convertStringToNumber +} from '../utilities/unitConverters'; +import { + zeroArgumentsValidator, + singleArgumentValidator, + zeroOrOneArgumentValidator, + altitudeValidator, + fixValidator, + headingValidator, + holdValidator +} from './argumentValidators'; +import { + altitudeParser, + headingParser, + holdParser +} from './argumentParsers'; + +/** + * A no-op function used for command definitions that do not need a parser + * + * This function will immediately return any arguments passed to it and is + * used in place of an actual parser. this way `command.parse` can still + * be called even with commands that don't need to be parsed. + * + * @function noop + * @param args {*} + * @return {*} + */ +const noop = (args) => args; + +/** + * System and Aircraft command definitions that accept zero arguments + * + * @property ZERO_ARG_COMMANDS + * @type {Object} + * @final + */ +const ZERO_ARG_COMMANDS = { + // system commands + auto: { + validate: zeroArgumentsValidator, + parse: noop + }, + clear: { + validate: zeroArgumentsValidator, + parse: noop + }, + pause: { + validate: zeroArgumentsValidator, + parse: noop + }, + tutorial: { + validate: zeroArgumentsValidator, + parse: noop + }, + version: { + validate: zeroArgumentsValidator, + parse: noop + }, + + // Aircraft commands + abort: { + validate: zeroArgumentsValidator, + parse: noop + }, + clearedAsFiled: { + validate: zeroArgumentsValidator, + parse: noop + }, + climbViaSID: { + validate: zeroArgumentsValidator, + parse: noop + }, + debug: { + validate: zeroArgumentsValidator, + parse: noop + }, + delete: { + validate: zeroArgumentsValidator, + parse: noop + }, + descendViaSTAR: { + validate: zeroArgumentsValidator, + parse: noop + }, + flyPresentHeading: { + validate: zeroArgumentsValidator, + parse: noop + }, + sayRoute: { + validate: zeroArgumentsValidator, + parse: noop + }, + takeoff: { + validate: zeroArgumentsValidator, + parse: noop + } +}; + +/** + * System and Aircraft commands that accept a single argument + * + * these commands accept a single argument and may require further parsing, eg: (string -> number) + * + * @property SINGLE_ARG_COMMANDS + * @type {Object} + * @final + */ +const SINGLE_ARG_COMMANDS = { + '`': { + validate: singleArgumentValidator, + // calling method is expecting an array with values that will get spread later, thus we purposly + // return an array here + parse: (args) => [convertStringToNumber(args)] + }, + airport: { + validate: singleArgumentValidator, + parse: noop + }, + rate: { + validate: singleArgumentValidator, + // calling method is expecting an array with values that will get spread later, thus we purposly + // return an array here + parse: (args) => [convertStringToNumber(args)] + }, + timewarp: { + validate: singleArgumentValidator, + // calling method is expecting an array with values that will get spread later, thus we purposly + // return an array here + parse: (args) => [convertStringToNumber(args)] + }, + + direct: { + validate: singleArgumentValidator, + parse: noop + }, + land: { + validate: singleArgumentValidator, + // TODO: split this out to custom parser once the null value is defined + parse: (args) => [null, args[0]] + }, + moveDataBlock: { + validate: singleArgumentValidator, + parse: noop + }, + route: { + validate: singleArgumentValidator, + parse: noop + }, + reroute: { + validate: singleArgumentValidator, + parse: noop + }, + sid: { + validate: singleArgumentValidator, + parse: noop + }, + speed: { + validate: singleArgumentValidator, + // calling method is expecting an array with values that will get spread later, thus we purposly + // return an array here + parse: (arg) => [convertStringToNumber(arg)] + }, + star: { + validate: singleArgumentValidator, + parse: noop + } +}; + +/** + * System and Aircraft commands that accept arguments specific to the command + * + * These definitions will likely reference functions for validate and parse that are specific only + * to one command + * + * @property CUSTOM_ARG_COMMANDS + * @type {Object} + * @final + */ +const CUSTOM_ARG_COMMANDS = { + taxi: { + validate: zeroOrOneArgumentValidator, + parse: noop + }, + + // these commands have specific argument requirements and may need to be parsed + // into the correct type (sting -> number) + altitude: { + validate: altitudeValidator, + parse: altitudeParser + }, + fix: { + validate: fixValidator, + parse: noop + }, + heading: { + validate: headingValidator, + parse: headingParser + }, + hold: { + validate: holdValidator, + parse: holdParser + } +}; + +/** + * Single exported constant that combines all the definitions above + * + * @property COMMAND_DEFINITION + * @type {Object} + * @final + */ +export const COMMAND_DEFINITION = { + ...ZERO_ARG_COMMANDS, + ...SINGLE_ARG_COMMANDS, + ...CUSTOM_ARG_COMMANDS +}; diff --git a/src/assets/scripts/commandParser/commandMap.js b/src/assets/scripts/commandParser/commandMap.js new file mode 100644 index 00000000..b53e254c --- /dev/null +++ b/src/assets/scripts/commandParser/commandMap.js @@ -0,0 +1,123 @@ +/** + * List of System Commands + * + * When a command is parsed, the value here will be used for the `name` property + * of the `CommandParser` + * + * @property SYSTEM_COMMANDS + * @type {Object} + * @final + */ +export const SYSTEM_COMMANDS = { + auto: 'auto', + clear: 'clear', + pause: 'pause', + tutorial: 'tutorial', + version: 'version', + + // single arg commands + '`': 'moveDataBlock', + airport: 'airport', + rate: 'rate', + timewarp: 'timewarp', + transmit: 'transmit' +}; + +/** + * Some commands are converted to unicode (to provide arrow characters) for specific shortkeys + * + * This maps those unicode values, converted to a string, to the correct root command + * + * @property UNICODE_COMMANDS + * @type {Object} + * @final + */ +const UNICODE_COMMANDS = { + '\\u2B61': 'altitude', + '\\u2B63': 'altitude', + '\\u2BA2': 'heading', + '\\u2BA3': 'heading', + '\\u2B50': 'land' +}; + +/** + * Complete map of commands + * + * This list includes both System and Unicode commands, as well as all the various aircraft + * commands. + * + * Aliased commands map to a single root command that is shared among all aliases. The values + * here then map to a `COMMAND_DEFINITION` which contains `validate` and `parse` functions for + * each root command. Some commands have very unique demands for how arguments are formatted, + * those functions let us do that on a case by case basis. + * + * Keys are lowercased here so they can be accessed programatically using input string segments + * that are converted to lowercase for ease of comparison. + * + * @propery COMMAND_MAP + * @type {Object} + * @final + */ +export const COMMAND_MAP = { + ...SYSTEM_COMMANDS, + ...UNICODE_COMMANDS, + + taxi: 'taxi', + wait: 'taxi', + w: 'taxi', + sid: 'sid', + star: 'star', + clearedAsFiled: 'clearedAsFiled', + caf: 'clearedAsFiled', + climbViaSID: 'climbViaSID', + cvs: 'climbViaSID', + descendViaSTAR: 'descendViaSTAR', + dvs: 'descendViaSTAR', + climb: 'altitude', + c: 'altitude', + descend: 'altitude', + d: 'altitude', + altitude: 'altitude', + a: 'altitude', + takeoff: 'takeoff', + to: 'takeoff', + cto: 'takeoff', + fph: 'flyPresentHeading', + heading: 'heading', + fh: 'heading', + h: 'heading', + turn: 'heading', + t: 'heading', + speed: 'speed', + slow: 'speed', + sp: 'speed', + '+': 'speed', + '-': 'speed', + ils: 'land', + i: 'land', + land: 'land', + l: 'land', + '*': 'land', + reroute: 'reroute', + rr: 'reroute', + route: 'route', + sr: 'sayRoute', + f: 'fix', + fix: 'fix', + track: 'fix', + direct: 'direct', + pd: 'direct', + dct: 'direct', + abort: 'abort', + hold: 'hold', + delete: 'delete', + del: 'delete', + kill: 'delete' +}; + +/** + * @property EXPEDITE + * @type {array} + * @final + */ +export const EXPEDITE = ['expedite', 'x']; diff --git a/src/assets/scripts/commandParser/commandParserMessages.js b/src/assets/scripts/commandParser/commandParserMessages.js new file mode 100644 index 00000000..e5bfe4f1 --- /dev/null +++ b/src/assets/scripts/commandParser/commandParserMessages.js @@ -0,0 +1,37 @@ +/* eslint-disable max-len */ +/** + * @property INVALID_ARG + * @type {string} + * @final + */ +const INVALID_ARG = 'Invalid argument'; + +/** + * @property INVALID_ARG_LENGTH + * @type {string} + * @final + */ +const INVALID_ARG_LENGTH = `${INVALID_ARG} length`; + +/** + * Encapsulation of error messaging used with `argumentValidators` functions + * + * @property ERROR_MESSAGE + * @type {Object} + * @final + */ +export const ERROR_MESSAGE = { + ZERO_ARG_LENGTH: `${INVALID_ARG_LENGTH}. Expected exactly zero arguments`, + SINGLE_ARG_LENGTH: `${INVALID_ARG_LENGTH}. Expected exactly one argument`, + ZERO_OR_ONE_ARG_LENGTH: `${INVALID_ARG_LENGTH}. Expected zero or one argument`, + ZERO_TO_THREE_ARG_LENGTH: `${INVALID_ARG_LENGTH}. Expected zero to three arguments`, + ONE_OR_MORE_ARG_LENGTH: `${INVALID_ARG_LENGTH}. Expected one or more arguments`, + ONE_OR_TWO_ARG_LENGTH: `${INVALID_ARG_LENGTH}. Expected one or two arguments`, + ONE_TO_THREE_ARG_LENGTH: `${INVALID_ARG_LENGTH}. Expected one, two, or three arguments`, + ONE_OR_THREE_ARG_LENGTH: `${INVALID_ARG_LENGTH}. Expected one or three arguments`, + ALTITUDE_EXPEDITE_ARG: `${INVALID_ARG}. Altitude accepts only "expedite" or "x" as a second argument`, + HEADING_MUST_BE_NUMBER: `${INVALID_ARG}. Heading must be a number`, + MUST_BE_STRING: `${INVALID_ARG}. Must be a string`, + INVALID_DIRECTION_STRING: `${INVALID_ARG}. Expected one of 'left / l / right / r' as the first argument when passed three arguments`, + HEADING_ACCEPTS_BOOLEAN_AS_THIRD_ARG: `${INVALID_ARG}. Heading accepts a boolean for the third argument when passed three arguments` +}; diff --git a/src/assets/scripts/constants/globalConstants.js b/src/assets/scripts/constants/globalConstants.js index 75dc6666..52c0e080 100644 --- a/src/assets/scripts/constants/globalConstants.js +++ b/src/assets/scripts/constants/globalConstants.js @@ -1,11 +1,13 @@ +/** + * Commonly used time conversion rates + * + * @property TIME + * @type {Object} + * @final + */ export const TIME = { - /** - * @property ONE_HOUR_IN_SECONDS - * @type {number} - * @final - */ - ONE_HOUR_IN_MINUTES: 60, ONE_HOUR_IN_SECONDS: 3600, + ONE_HOUR_IN_MINUTES: 60, ONE_HOUR_IN_MILLISECONDS: 3600000, ONE_MINUTE_IN_HOURS: 1 / 60, ONE_MINUTE_IN_SECONDS: 60, @@ -28,5 +30,6 @@ export const TIME = { export const REGEX = { COMPASS_DIRECTION: /^[NESW]/, SW: /[SW]/, - LAT_LONG: /^([NESW])(\d+(\.\d+)?)([d °](\d+(\.\d+)?))?([m '](\d+(\.\d+)?))?$/ + LAT_LONG: /^([NESW])(\d+(\.\d+)?)([d °](\d+(\.\d+)?))?([m '](\d+(\.\d+)?))?$/, + UNICODE: /[^\u0000-\u00ff]/ }; diff --git a/src/assets/scripts/parser.js b/src/assets/scripts/parser.js index 92bd2979..91b90954 100644 --- a/src/assets/scripts/parser.js +++ b/src/assets/scripts/parser.js @@ -1,6 +1,6 @@ /* eslint-disable */ zlsa.atc.Parser = (function() { - + /* * Generated by PEG.js 0.9.0. * diff --git a/src/assets/scripts/utilities/generalUtilities.js b/src/assets/scripts/utilities/generalUtilities.js index d756d4db..33baae1a 100644 --- a/src/assets/scripts/utilities/generalUtilities.js +++ b/src/assets/scripts/utilities/generalUtilities.js @@ -1,5 +1,14 @@ import _isArray from 'lodash/isArray'; +/** + * Helper method to translate a unicode character into a readable string value + * + * @method unicodeToString + * @param char {characterCode} + * @return {string} + */ +export const unicodeToString = (char) => `\\u${char.charCodeAt(0).toString(16).toUpperCase()}`; + /** * * @function choose diff --git a/src/assets/scripts/utilities/unitConverters.js b/src/assets/scripts/utilities/unitConverters.js index aa46e4af..a38b2998 100644 --- a/src/assets/scripts/utilities/unitConverters.js +++ b/src/assets/scripts/utilities/unitConverters.js @@ -5,6 +5,13 @@ import { tau } from '../math/circle'; import { round, mod } from '../math/core'; import { TIME, REGEX } from '../constants/globalConstants'; +/** + * @property DECIMAL_RADIX + * @type {number} + * @final + */ +const DECIMAL_RADIX = 10; + // TODO: This should be moved to its own file once it has been filled in a little more /** * @property UNIT_CONVERSION_CONSTANTS @@ -42,7 +49,15 @@ export const UNIT_CONVERSION_CONSTANTS = { * @type {number} * @final */ - KN_MS: 0.51444444 + KN_MS: 0.51444444, + /** + * Number used to obtain feet from a flight level number + * + * @property FL_TO_FT_MULTIPLIER + * @type {number} + * @final + */ + FL_TO_FT_MULTIPLIER: 100 }; // TODO: This should be moved to its own file once it has been filled in a little more @@ -176,14 +191,34 @@ export const km_to_px = (kilometers, scale) => { }; /** - * - * * @function convertMinutesToSeconds * @param minutes {number} * @return {number} */ export const convertMinutesToSeconds = (minutes) => minutes * 60; +/** + * Utility function to convert a number to thousands. + * + * Given a flightlevel FL180, this function outputs 18,000 + * + * @function covertToThousands + * @param {number} value + * @return {number} + */ +export const convertToThousands = (value) => parseInt(value, DECIMAL_RADIX) * UNIT_CONVERSION_CONSTANTS.FL_TO_FT_MULTIPLIER; + +/** + * Attempt to convert a string to a number + * + * The implementor will have to handle the case where `parseInt` returns `NaN` + * + * @function convertStringToNumber + * @param value {string|*} + * @return {number|NaN} + */ +export const convertStringToNumber = (value) => parseInt(value, DECIMAL_RADIX); + /** * * @function heading_to_string @@ -266,7 +301,9 @@ export const parseElevation = (elevation) => { // if its a number, we're done here. // This will catch whole numbers, floats, Infinity and -Infinity. - if (_isNumber(elevation)) { + // This checks if strings are given will skip the regex and exit early + // Also stops the function from returning NaN + if (_isNumber(elevation) || elevation === 'Infinity' || elevation === '-Infinity') { return parseFloat(elevation); } @@ -285,3 +322,4 @@ export const parseElevation = (elevation) => { return parseFloat(parsedElevation); }; + diff --git a/test/commandParser/CommandModel.spec.js b/test/commandParser/CommandModel.spec.js new file mode 100644 index 00000000..ca55626f --- /dev/null +++ b/test/commandParser/CommandModel.spec.js @@ -0,0 +1,30 @@ +/* eslint-disable arrow-parens, max-len, import/no-extraneous-dependencies */ +import ava from 'ava'; + +import CommandModel from '../../src/assets/scripts/commandParser/CommandModel'; + + +ava('does not thow when instantiated without parameters', t => { + t.notThrows(() => new CommandModel()); +}); + +// ava('#parsedArgs returns a string if the arg is a string', t => { +// const model = new CommandModel('heading'); +// model.args.push('right'); +// +// t.true(typeof model.parsedArgs[0] === 'string'); +// }); +// +// ava('#parsedArgs returns a number if the arg is a number', t => { +// const model = new CommandModel('heading'); +// model.args.push('180'); +// +// t.true(typeof model.parsedArgs[0] === 'number'); +// }); +// +// ava('#parsedArgs returns a string padded by 0 if original arg is padded by 0', t => { +// const model = new CommandModel('heading'); +// model.args.push('090'); +// +// t.true(model.parsedArgs[0] === '090'); +// }); diff --git a/test/commandParser/CommandParser.spec.js b/test/commandParser/CommandParser.spec.js new file mode 100644 index 00000000..119581fd --- /dev/null +++ b/test/commandParser/CommandParser.spec.js @@ -0,0 +1,191 @@ +/* eslint-disable arrow-parens, max-len, import/no-extraneous-dependencies */ +import ava from 'ava'; +import sinon from 'sinon'; +import _isEqual from 'lodash/isEqual'; +import _map from 'lodash/map'; +import _tail from 'lodash/tail'; + +import CommandParser from '../../src/assets/scripts/commandParser/CommandParser'; +import CommandModel from '../../src/assets/scripts/commandParser/CommandModel'; + +const TIMEWARP_50_MOCK = 'timewarp 50'; +const CALLSIGN_MOCK = 'AA777'; +const CAF_MOCK = 'caf'; +const CVS_MOCK = 'cvs'; +const TO_MOCK = 'to'; +const FH_COMMAND_MOCK = 'fh 180'; +const D_COMMAND_MOCK = 'd 030'; +const STAR_MOCK = 'star quiet7'; +const ROUTE_MOCK = 'route KSEA.MTN7.ELN..HAMUR.J12.DNJ'; +const COMPLEX_HOLD_MOCK = 'hold dumba right 2min'; +const UNICODE_HEADING_MOCK = '\u2BA2 180' + +const buildCommandString = (...args) => `${CALLSIGN_MOCK} ${args.join(' ')}`; + +const buildCommandList = (...args) => { + const commandString = buildCommandString(...args); + + return commandString.split(' '); +}; + +ava('throws when called without parameters', t => { + t.throws(() => new CommandParser(false)); + t.throws(() => new CommandParser(42)); + t.throws(() => new CommandParser({})); + + t.notThrows(() => new CommandParser()); +}); + +ava('throws when called with an invalid command', (t) => { + t.throws(() => new CommandParser(['threeve'])); +}); + +ava('throws when called with invalid arguments', (t) => { + const commandStringMock = buildCommandString(TO_MOCK, 'threeve'); + + t.throws(() => new CommandParser(commandStringMock)); +}); + +ava('#args returns one item when a system command is present', t => { + const model = new CommandParser(TIMEWARP_50_MOCK); + + t.true(_isEqual(model.args, [50])); +}); + +ava('#args an array for each command with arg values when a transmit command is present', t => { + const commandStringMock = buildCommandString(FH_COMMAND_MOCK, D_COMMAND_MOCK, STAR_MOCK); + const model = new CommandParser(commandStringMock); + + t.true(model.args.length === 3); +}); + +ava('sets #command with the correct name when provided a system command', t => { + const model = new CommandParser(TIMEWARP_50_MOCK); + + t.true(model.command === 'timewarp'); +}); + +ava('sets #command with the correct name when provided a transmit command', t => { + const commandStringMock = buildCommandString(CAF_MOCK, CVS_MOCK, TO_MOCK); + const model = new CommandParser(commandStringMock); + + t.true(model.command === 'transmit'); +}); + +ava('sets #commandList with a CommandModel object when provided a system command', t => { + const model = new CommandParser(TIMEWARP_50_MOCK); + + t.true(model.commandList.length === 1); + t.true(model.commandList[0] instanceof CommandModel); +}); + +ava('sets #commandList with CommandModel objects when it receives transmit commands', t => { + const commandStringMock = buildCommandString(CAF_MOCK, CVS_MOCK, TO_MOCK); + const model = new CommandParser(commandStringMock); + + t.true(model.commandList.length === 3); + + _map(model.commandList, (command) => { + t.true(command instanceof CommandModel); + }); +}); + +ava('._extractCommandsAndArgs() calls _buildCommandList() when provided transmit commands', t => { + const commandStringMock = buildCommandString(CAF_MOCK, CVS_MOCK, TO_MOCK); + const expectedArgs = buildCommandList(CAF_MOCK, CVS_MOCK, TO_MOCK); + const model = new CommandParser(commandStringMock); + const _buildCommandListSpy = sinon.spy(model, '_buildCommandList'); + + model._extractCommandsAndArgs(commandStringMock); + + t.true(_buildCommandListSpy.calledWithExactly(_tail(expectedArgs))); +}); + +ava('._buildCommandList() finds correct command when it recieves a space before a unicode value', t => { + const commandListMock = buildCommandList('', UNICODE_HEADING_MOCK); + const model = new CommandParser(buildCommandString('', UNICODE_HEADING_MOCK)); + const result = model._buildCommandList(_tail(commandListMock)); + + t.true(result[0].name === 'heading'); + t.true(result[0].args[0] === '180'); +}); + +ava('._buildCommandList() does not throw when it trys to add args to an undefined commandModel and returns an empty array', t => { + const model = new CommandParser(); + + t.notThrows(() => model._buildCommandList(['threeve', '$texas'])); + + const result = model._buildCommandList(['threeve', '$texas']); + + t.true(result.length === 0); +}); + +ava('._validateAndParseCommandArguments() calls ._validateCommandArguments()', t => { + const commandStringMock = buildCommandString(CAF_MOCK, CVS_MOCK, TO_MOCK); + const model = new CommandParser(commandStringMock); + + const _validateCommandArgumentsSpy = sinon.spy(model, '_validateCommandArguments'); + model._validateCommandArguments(); + + t.true(_validateCommandArgumentsSpy.called); +}); + +ava('._isSystemCommand() returns true if callsignOrTopLevelCommandName exists within SYSTEM_COMMANDS and is not transmit', t => { + const systemCommandMock = 'timewarp'; + const model = new CommandParser(); + + t.true(model._isSystemCommand(systemCommandMock)); +}); + +// specific use case tests +ava('when passed t l 042 as a command it adds l as an argument and not a new command', t => { + const commandStringMock = buildCommandString('t', 'l', '042'); + const model = new CommandParser(commandStringMock); + + t.true(model.args[0][0] === 'heading'); + t.true(model.args[0][1] === 'left'); +}); + +ava('when passed l as command it adds land as a new command', t => { + const commandStringMock = buildCommandString('t', 'l', '042', 'l', '28l'); + const model = new CommandParser(commandStringMock); + + t.true(model.args[0][0] === 'heading'); + t.true(model.args[0][1] === 'left'); + t.true(model.args[1][0] === 'land'); + t.true(model.args[1][2] === '28l'); +}); + +ava('when passed hold LAM it creates the correct command with the correct arguments', t => { + const commandStringMock = buildCommandString('hold', 'LAM'); + const model = new CommandParser(commandStringMock); + + t.true(model.args[0][0] === 'hold'); + t.true(model.args[0][1] === null); + t.true(model.args[0][2] === null); + t.true(model.args[0][3] === 'lam'); +}); + +ava('when passed dct WHAMY it creates the correct command with the correct arguments', t => { + const commandStringMock = buildCommandString('dct', 'WHAMY'); + const model = new CommandParser(commandStringMock); + + t.true(model.args[0][0] === 'direct'); + t.true(model.args[0][1] === 'whamy'); +}); + +ava('when passed dct TOU it creates the correct command with the correct arguments', t => { + const commandStringMock = buildCommandString('dct', 'TOU'); + const model = new CommandParser(commandStringMock); + + t.true(model.args[0][0] === 'direct'); + t.true(model.args[0][1] === 'tou'); +}); + +ava('when passed dct TOR it creates the correct command with the correct arguments', t => { + const commandStringMock = buildCommandString('dct', 'TOR'); + const model = new CommandParser(commandStringMock); + + t.true(model.args[0][0] === 'direct'); + t.true(model.args[0][1] === 'tor'); +}); diff --git a/test/commandParser/argumentParser.spec.js b/test/commandParser/argumentParser.spec.js new file mode 100644 index 00000000..60cc9366 --- /dev/null +++ b/test/commandParser/argumentParser.spec.js @@ -0,0 +1,133 @@ +/* eslint-disable arrow-parens, max-len, import/no-extraneous-dependencies*/ +import ava from 'ava'; +import _isEqual from 'lodash/isEqual'; + +import { + altitudeParser, + headingParser, + findHoldCommandByType, + holdParser +} from '../../src/assets/scripts/commandParser/argumentParsers'; + +ava('.altitudeParser() converts a string flight level altitude to a number altitude in thousands', t => { + const result = altitudeParser(['080']); + + t.true(result[0] === 8000); +}); + +ava('.altitudeParser() returns true if the second argument is not undefined', t => { + const result = altitudeParser(['080', 'x']); + + t.true(result[1]); +}); + +ava('.altitudeParser() returns an array of length two when passed a single argument', t => { + const result = altitudeParser(['080']); + + t.true(result.length === 2); + t.true(result[0] === 8000); + t.false(result[1]); +}); + +ava('.headingParser() throws if it does not receive 1 or 2 arguments', t => { + t.throws(() => headingParser([])); + t.throws(() => headingParser(['l', '042', 'threeve'])); +}); + +ava('.headingParser() returns an array of length 3 when passed new heading as the second argument', t => { + const result = headingParser(['042']); + + t.true(result.length === 3); + t.true(result[0] === null); + t.true(result[1] === 42); + t.false(result[2]); +}); + +ava('.headingParser() returns an array of length 3 when passed direction and heading as arguments', t => { + const result = headingParser(['left', '42']); + + t.true(result.length === 3); + t.true(result[0] === 'left'); + t.true(result[1] === 42); + t.true(result[2]); +}); + +ava('.headingParser() translates l to left as the first value', t => { + const result = headingParser(['l', '042']); + + t.true(result[0] === 'left'); +}); + +ava('.headingParser() translates r to right as the first value', t => { + const result = headingParser(['r', '042']); + + t.true(result[0] === 'right'); +}); + +// specfic use cases for headingParser +ava('.headingParser() parses two digit heading as an incremental heading', t => { + const result = headingParser(['r', '42']); + + t.true(result[0] === 'right'); + t.true(result[1] === 42); + t.true(result[2]); +}); + +ava('.headingParser() parses three digit heading as a generic heading', t => { + const result = headingParser(['r', '042']); + + t.true(result[0] === 'right'); + t.true(result[1] === 42); + t.false(result[2]); +}); + +ava('.findHoldCommandByType() returns a turnDirection when passed a variation of left or right', (t) => { + const argsMock = ['dumba', 'l', '3nm']; + t.true(findHoldCommandByType('turnDirection', argsMock) === 'left'); +}); + +ava('.findHoldCommandByType() returns a legLength when passed a valid legLength in min', (t) => { + const argsMock = ['dumba', 'l', '3min']; + t.true(findHoldCommandByType('legLength', argsMock) === '3min'); +}); + +ava('.findHoldCommandByType() returns a legLength when passed a valid legLength in nm', (t) => { + const argsMock = ['dumba', 'l', '3nm']; + t.true(findHoldCommandByType('legLength', argsMock) === '3nm'); +}); + +ava('.findHoldCommandByType() returns a fixName when passed a valid fixName', (t) => { + const argsMock = ['dumba', 'l', '3nm']; + t.true(findHoldCommandByType('fixName', argsMock) === 'dumba'); +}); + +ava('.holdParser() returns an array of length 3 when passed a fixname as the only argument', t => { + const expectedResult = [null, null, 'dumba']; + const result = holdParser(['dumba']); + + t.true(_isEqual(result, expectedResult)); +}); + +ava('.holdParser() returns an array of length 3 when passed a direction and fixname as arguments', t => { + const expectedResult = ['left', null, 'dumba']; + let result = holdParser(['dumba', 'left']); + t.true(_isEqual(result, expectedResult)); + + result = holdParser(['left', 'dumba']); + t.true(_isEqual(result, expectedResult)); +}); + +ava('.holdParser() returns an array of length 3 when passed a direction, legLength and fixname as arguments', t => { + const expectedResult = ['left', '1min', 'dumba']; + let result = holdParser(['dumba', 'left', '1min']); + t.true(_isEqual(result, expectedResult)); + + result = holdParser(['left', '1min', 'dumba']); + t.true(_isEqual(result, expectedResult)); + + result = holdParser(['1min', 'left', 'dumba']); + t.true(_isEqual(result, expectedResult)); + + result = holdParser(['left', 'dumba', '1min']); + t.true(_isEqual(result, expectedResult)); +}); diff --git a/test/commandParser/argumentValidator.spec.js b/test/commandParser/argumentValidator.spec.js new file mode 100644 index 00000000..c1d7a90c --- /dev/null +++ b/test/commandParser/argumentValidator.spec.js @@ -0,0 +1,224 @@ +/* eslint-disable arrow-parens, max-len, import/no-extraneous-dependencies */ +import ava from 'ava'; + +import { + zeroArgumentsValidator, + singleArgumentValidator, + zeroOrOneArgumentValidator, + oneOrTwoArgumentValidator, + oneToThreeArgumentsValidator, + oneOrThreeArgumentsValidator, + altitudeValidator, + fixValidator, + headingValidator, + holdValidator +} from '../../src/assets/scripts/commandParser/argumentValidators'; + +// TODO: import ERROR_MESSAGE and use actual values to test against + +ava('.zeroArgumentsValidator() returns a string when passed the wrong number of arguments', t => { + let result = zeroArgumentsValidator(); + t.true(typeof result === 'undefined'); + + result = zeroArgumentsValidator([]); + t.true(typeof result === 'undefined'); + + result = zeroArgumentsValidator(['', '']); + t.true(result === 'Invalid argument length. Expected exactly zero arguments'); +}); + +ava('.singleArgumentValidator() returns a string when passed the wrong number of arguments', t => { + let result = singleArgumentValidator(['']); + t.true(typeof result === 'undefined'); + + result = singleArgumentValidator(); + t.true(result === 'Invalid argument length. Expected exactly one argument'); + + result = singleArgumentValidator([]); + t.true(result === 'Invalid argument length. Expected exactly one argument'); + + result = singleArgumentValidator(['', '']); + t.true(result === 'Invalid argument length. Expected exactly one argument'); +}); + +ava('.zeroOrOneArgumentValidator() returns a string when passed the wrong number of arguments', t => { + let result = zeroOrOneArgumentValidator(); + t.true(typeof result === 'undefined'); + + result = zeroOrOneArgumentValidator(['']); + t.true(typeof result === 'undefined'); + + result = zeroOrOneArgumentValidator(['', '']); + t.true(result === 'Invalid argument length. Expected zero or one argument'); +}); + +ava('.oneOrTwoArgumentValidator() returns a string when passed the wrong number of arguments', t => { + let result = oneOrTwoArgumentValidator(['']); + t.true(typeof result === 'undefined'); + + result = oneOrTwoArgumentValidator(['', '']); + t.true(typeof result === 'undefined'); + + result = oneOrTwoArgumentValidator(); + t.true(result === 'Invalid argument length. Expected one or two arguments'); + + result = oneOrTwoArgumentValidator(['', '', '']); + t.true(result === 'Invalid argument length. Expected one or two arguments'); +}); + +ava('.oneToThreeArgumentsValidator() returns a string when passed the wrong number of arguments', t => { + let result = oneToThreeArgumentsValidator(['']); + t.true(typeof result === 'undefined'); + + result = oneToThreeArgumentsValidator(['', '']); + t.true(typeof result === 'undefined'); + + result = oneToThreeArgumentsValidator(['', '', '']); + t.true(typeof result === 'undefined'); + + result = oneToThreeArgumentsValidator(); + t.true(result === 'Invalid argument length. Expected one, two, or three arguments'); + + result = oneToThreeArgumentsValidator(['', '', '', '']); + t.true(result === 'Invalid argument length. Expected one, two, or three arguments'); +}); + +ava('.oneOrThreeArgumentValidator() returns a string when passed the wrong number of arguments', t => { + let result = oneOrThreeArgumentsValidator(['']); + t.true(typeof result === 'undefined'); + + result = oneOrThreeArgumentsValidator(['', '', '']); + t.true(typeof result === 'undefined'); + + result = oneOrThreeArgumentsValidator(); + t.true(result === 'Invalid argument length. Expected one or three arguments'); + + result = oneOrThreeArgumentsValidator(['', '', '', '']); + t.true(result === 'Invalid argument length. Expected one or three arguments'); +}); + +ava('.altitudeValidator() returns a string when passed the wrong number of arguments', t => { + let result = altitudeValidator(['']); + t.true(typeof result === 'undefined'); + + result = altitudeValidator(['', 'expedite']); + t.true(typeof result === 'undefined'); + + result = altitudeValidator(); + t.true(result === 'Invalid argument length. Expected one or two arguments'); + + result = altitudeValidator([]); + t.true(result === 'Invalid argument length. Expected one or two arguments'); + + result = altitudeValidator(['', '', '']); + t.true(result === 'Invalid argument length. Expected one or two arguments'); +}); + +ava('.altitudeValidator() returns a string when passed anything other than expedite or x as the second argument', t => { + let result = altitudeValidator(['', 'expedite']); + t.true(typeof result === 'undefined'); + + result = altitudeValidator(['', '']); + t.true(result === 'Invalid argument. Altitude accepts only "expedite" or "x" as a second argument'); +}); + +ava('.fixValidator() returns undefined when it receives at least one valid argument', (t) => { + let result = fixValidator(['one']); + t.true(typeof result === 'undefined'); + + result = fixValidator(['one', 'two', 'th33', '4F1o']); + t.true(typeof result === 'undefined'); + + t.true(fixValidator([]) === 'Invalid argument length. Expected one or more arguments'); +}); + +ava('.fixValidator() returns a string when passed anything other than a string', (t) => { + t.true(fixValidator([42, '', '']) === 'Invalid argument. Must be a string'); + t.true(fixValidator(['', false, '']) === 'Invalid argument. Must be a string'); + t.true(fixValidator([42, false, '', {}]) === 'Invalid argument. Must be a string'); +}); + +ava('.headingValidator() returns a string when passed the wrong number of arguments', t => { + let result = headingValidator(['042']); + t.true(typeof result === 'undefined'); + + result = headingValidator(['l', '42']); + t.true(typeof result === 'undefined'); + + result = headingValidator(); + t.true(result === 'Invalid argument length. Expected one or two arguments'); + + result = headingValidator([]); + t.true(result === 'Invalid argument length. Expected one or two arguments'); + + result = headingValidator(['l', '42', 'threeve']); + t.true(result === 'Invalid argument length. Expected one or two arguments'); +}); + +ava('.headingValidator() returns a string when passed the wrong type of arguments', t => { + t.true(headingValidator(['threeve']) === 'Invalid argument. Heading must be a number'); + t.true(headingValidator(['42', '42']) === 'Invalid argument. Expected one of \'left / l / right / r\' as the first argument when passed three arguments'); + t.true(headingValidator(['l', 'threeve']) === 'Invalid argument. Heading must be a number'); + t.true(headingValidator(['42', '42']) === 'Invalid argument. Expected one of \'left / l / right / r\' as the first argument when passed three arguments'); + t.true(headingValidator(['l', 'threeve']) === 'Invalid argument. Heading must be a number'); +}); + +ava('.headingValidator() returns undefined when passed a number as a single argument', t => { + const result = headingValidator(['042']); + t.true(typeof result === 'undefined'); +}); + +ava('.headingValidator() returns undefined when passed a string and a number as arguments', t => { + const result = headingValidator(['l', '042']); + t.true(typeof result === 'undefined'); +}); + +ava('.holdValidator() returns a string when passed the wrong number of arguments', t => { + const result = holdValidator(['', 'left', '1min', '']); + t.true(result === 'Invalid argument length. Expected zero to three arguments'); +}); + +ava('.holdValidator() returns undefined when passed zero arguments', t => { + let result = holdValidator(); + t.true(typeof result === 'undefined'); + + result = holdValidator([]); + t.true(typeof result === 'undefined'); +}); + +ava('.holdValidator() returns a string when passed the wrong type of arguments', t => { + t.true(holdValidator([false]) === 'Invalid argument. Must be a string'); + t.true(holdValidator([false, '42', '1min']) === 'Invalid argument. Must be a string'); + t.true(holdValidator(['42', false, '1min']) === 'Invalid argument. Must be a string'); + t.true(holdValidator(['42', 'left', false]) === 'Invalid argument. Must be a string'); +}); + +ava('.holdValidator() returns undefined when passed a string as an argument', t => { + const result = holdValidator(['']); + t.true(typeof result === 'undefined'); +}); + +ava('.holdValidator() returns undefined when two strings as arguments', t => { + let result = holdValidator(['dumba', '1min']); + t.true(typeof result === 'undefined'); + + result = holdValidator(['1nm', '1min']); + t.true(typeof result === 'undefined'); + + result = holdValidator(['l', 'dumba']); + t.true(typeof result === 'undefined'); +}); + +ava('.holdValidator() returns undefined when passed three strings as arguments', t => { + let result = holdValidator(['dumba', 'left', '1min']); + t.true(typeof result === 'undefined'); + + result = holdValidator(['dumba', 'right', '1nm']); + t.true(typeof result === 'undefined'); + + result = holdValidator(['dumba', 'right', '1min']); + t.true(typeof result === 'undefined'); + + result = holdValidator(['dumba', 'right', '1nm']); + t.true(typeof result === 'undefined'); +}); diff --git a/test/utilities/unitConverters.spec.js b/test/utilities/unitConverters.spec.js index e26a75e1..d0f2883b 100644 --- a/test/utilities/unitConverters.spec.js +++ b/test/utilities/unitConverters.spec.js @@ -129,4 +129,6 @@ ava('.parseElevation() should parse a string elevation into an elevation in feet t.true(parseElevation('-23m') === -75.45931758530183); t.true(parseElevation(Infinity) === Infinity); t.true(parseElevation(-Infinity) === -Infinity); + t.true(parseElevation('Infinity') === Infinity); + t.true(parseElevation('-Infinity') === -Infinity); });