diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index fbf35d3..2c5434e 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -19,10 +19,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up Python 3.8 + - name: Set up Python 3.11 uses: actions/setup-python@v3 with: - python-version: "3.8" + python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/README.md b/README.md index 0802e45..e259087 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ a city's bbox has been extended. A single city or a country with few metro networks can be validated much faster if you allow the `process_subway.py` to fetch data from Overpass API. Here are the steps: -1. Python3 interpreter required (3.8+) +1. Python3 interpreter required (3.11+) 2. Clone the repo ``` git clone https://github.com/alexey-zakharenkov/subways.git subways_validator @@ -84,8 +84,8 @@ if you allow the `process_subway.py` to fetch data from Overpass API. Here are t Summary information about all metro networks that are monitored is gathered in the [Google Spreadsheet](https://docs.google.com/spreadsheets/d/1SEW1-NiNOnA2qDwievcxYV1FOaQl1mb1fdeyqAxHu3k). -Regular updates of validation results are available at [Organic Maps](https://cdn.organicmaps.app/subway/) and -[mail.ru](https://maps.mail.ru/osm/tools/subways/latest/) servers. +Regular updates of validation results are available at +[this website](https://maps.mail.ru/osm/tools/subways/latest/). You can find more info about this validator instance in [OSM Wiki](https://wiki.openstreetmap.org/wiki/Quality_assurance#subway-preprocessor). diff --git a/process_subways.py b/process_subways.py index 89e1021..c81a21c 100755 --- a/process_subways.py +++ b/process_subways.py @@ -11,7 +11,6 @@ import time import urllib.parse import urllib.request from functools import partial -from typing import Dict, List, Optional, Tuple import processors from subway_io import ( @@ -30,17 +29,18 @@ from subway_structure import ( MODES_RAPID, ) - DEFAULT_SPREADSHEET_ID = "1SEW1-NiNOnA2qDwievcxYV1FOaQl1mb1fdeyqAxHu3k" DEFAULT_CITIES_INFO_URL = ( "https://docs.google.com/spreadsheets/d/" f"{DEFAULT_SPREADSHEET_ID}/export?format=csv" ) -Point = Tuple[float, float] +Point = tuple[float, float] -def overpass_request(overground, overpass_api, bboxes): +def overpass_request( + overground: bool, overpass_api: str, bboxes: list[list[float]] +) -> list[dict]: query = "[out:json][timeout:1000];(" modes = MODES_OVERGROUND if overground else MODES_RAPID for bbox in bboxes: @@ -65,7 +65,9 @@ def overpass_request(overground, overpass_api, bboxes): return json.load(response)["elements"] -def multi_overpass(overground, overpass_api, bboxes): +def multi_overpass( + overground: bool, overpass_api: str, bboxes: list[list[float]] +) -> list[dict]: SLICE_SIZE = 10 INTERREQUEST_WAIT = 5 # in seconds result = [] @@ -77,13 +79,13 @@ def multi_overpass(overground, overpass_api, bboxes): return result -def slugify(name): +def slugify(name: str) -> str: return re.sub(r"[^a-z0-9_-]+", "", name.lower().replace(" ", "_")) def get_way_center( - element: dict, node_centers: Dict[int, Point] -) -> Optional[Point]: + element: dict, node_centers: dict[int, Point] +) -> Point | None: """ :param element: dict describing OSM element :param node_centers: osm_id => (lat, lon) @@ -123,11 +125,11 @@ def get_way_center( def get_relation_center( element: dict, - node_centers: Dict[int, Point], - way_centers: Dict[int, Point], - relation_centers: Dict[int, Point], + node_centers: dict[int, Point], + way_centers: dict[int, Point], + relation_centers: dict[int, Point], ignore_unlocalized_child_relations: bool = False, -) -> Optional[Point]: +) -> Point | None: """ :param element: dict describing OSM element :param node_centers: osm_id => (lat, lon) @@ -176,14 +178,14 @@ def get_relation_center( return element["center"]["lat"], element["center"]["lon"] -def calculate_centers(elements): +def calculate_centers(elements: list[dict]) -> None: """Adds 'center' key to each way/relation in elements, except for empty ways or relations. Relies on nodes-ways-relations order in the elements list. """ - nodes: Dict[int, Point] = {} # id => (lat, lon) - ways: Dict[int, Point] = {} # id => (lat, lon) - relations: Dict[int, Point] = {} # id => (lat, lon) + nodes: dict[int, Point] = {} # id => (lat, lon) + ways: dict[int, Point] = {} # id => (lat, lon) + relations: dict[int, Point] = {} # id => (lat, lon) unlocalized_relations = [] # 'unlocalized' means the center of the # relation has not been calculated yet @@ -202,7 +204,7 @@ def calculate_centers(elements): def iterate_relation_centers_calculation( ignore_unlocalized_child_relations: bool, - ) -> List[int]: + ) -> list[dict]: unlocalized_relations_upd = [] for rel in unlocalized_relations: if center := get_relation_center( @@ -229,14 +231,16 @@ def calculate_centers(elements): unlocalized_relations = unlocalized_relations_upd -def add_osm_elements_to_cities(osm_elements, cities): +def add_osm_elements_to_cities( + osm_elements: list[dict], cities: list[City] +) -> None: for el in osm_elements: for c in cities: if c.contains(el): c.add(el) -def validate_cities(cities): +def validate_cities(cities: list[City]) -> list[City]: """Validate cities. Return list of good cities.""" good_cities = [] for c in cities: @@ -266,7 +270,7 @@ def validate_cities(cities): def get_cities_info( cities_info_url: str = DEFAULT_CITIES_INFO_URL, -) -> List[dict]: +) -> list[dict]: response = urllib.request.urlopen(cities_info_url) if ( not cities_info_url.startswith("file://") @@ -310,14 +314,14 @@ def get_cities_info( def prepare_cities( cities_info_url: str = DEFAULT_CITIES_INFO_URL, overground: bool = False -) -> List[City]: +) -> list[City]: if overground: raise NotImplementedError("Overground transit not implemented yet") cities_info = get_cities_info(cities_info_url) return list(map(partial(City, overground=overground), cities_info)) -def main(): +def main() -> None: parser = argparse.ArgumentParser() parser.add_argument( "--cities-info-url", diff --git a/scripts/process_subways.sh b/scripts/process_subways.sh index 1052d51..42c4af6 100755 --- a/scripts/process_subways.sh +++ b/scripts/process_subways.sh @@ -1,5 +1,5 @@ -#!/usr/bin/env bash -#set -euxo pipefail +#!/bin/bash +set -e -u if [ $# -gt 0 -a \( "${1-}" = "-h" -o "${1-}" = '--help' \) ]; then cat << EOF @@ -134,7 +134,7 @@ fi if [ -n "${NEED_FILTER-}" ]; then # If $PLANET_METRO file doesn't exist, create it - + if [ -n "${PLANET_METRO-}" ]; then EXT=${PLANET_METRO##*.} if [ ! "$EXT" = "osm" -a ! "$EXT" == "xml" -a ! "$EXT" = "o5m" ]; then @@ -235,9 +235,6 @@ if [ -n "${NEED_TO_REMOVE_POLY-}" ]; then fi # Running the validation -if [ -n "${DUMP-}" ]; then - mkdir -p "$DUMP" -fi if [ -n "${DUMP-}" ]; then mkdir -p "$DUMP" diff --git a/subway_structure.py b/subway_structure.py index 63bf63f..86a0f30 100644 --- a/subway_structure.py +++ b/subway_structure.py @@ -1,6 +1,7 @@ import math import re from collections import Counter, defaultdict +from itertools import islice from css_colours import normalize_colour @@ -1161,18 +1162,21 @@ class Route: tracks = self.get_truncated_tracks(tracks) return tracks - def check_stops_order_by_angle(self): + def check_stops_order_by_angle(self) -> tuple[list, list]: disorder_warnings = [] disorder_errors = [] - for si in range(len(self.stops) - 2): + for i, route_stop in enumerate( + islice(self.stops, 1, len(self.stops) - 1), start=1 + ): angle = angle_between( - self.stops[si].stop, - self.stops[si + 1].stop, - self.stops[si + 2].stop, + self.stops[i - 1].stop, + route_stop.stop, + self.stops[i + 1].stop, ) if angle < ALLOWED_ANGLE_BETWEEN_STOPS: msg = ( - f'Angle between stops around "{self.stops[si + 1]}" ' + "Angle between stops around " + f'"{route_stop.stoparea.name}" {route_stop.stop} ' f"is too narrow, {angle} degrees" ) if angle < DISALLOWED_ANGLE_BETWEEN_STOPS: @@ -1181,7 +1185,7 @@ class Route: disorder_warnings.append(msg) return disorder_warnings, disorder_errors - def check_stops_order_on_tracks_direct(self, stop_sequence): + def check_stops_order_on_tracks_direct(self, stop_sequence) -> str | None: """Checks stops order on tracks, following stop_sequence in direct order only. :param stop_sequence: list of dict{'route_stop', 'positions_on_rails', @@ -1206,12 +1210,13 @@ class Route: allowed_order_violations -= 1 else: route_stop = stop_data["route_stop"] - return 'Stops on tracks are unordered near "{}" {}'.format( - route_stop.stoparea.name, route_stop.stop + return ( + "Stops on tracks are unordered near " + f'"{route_stop.stoparea.name}" {route_stop.stop}' ) max_position_on_rails = positions_on_rails[suitable_occurrence] - def check_stops_order_on_tracks(self, projected_stops_data): + def check_stops_order_on_tracks(self, projected_stops_data) -> str | None: """Checks stops order on tracks, trying direct and reversed order of stops in the stop_sequence. :param projected_stops_data: info about RouteStops that belong to the @@ -1517,25 +1522,23 @@ class City: self.errors = [] self.warnings = [] self.notices = [] - self.id = int(city_data["id"]) + self.try_fill_int_attribute(city_data, "id") self.name = city_data["name"] self.country = city_data["country"] self.continent = city_data["continent"] self.overground = overground if not overground: - self.num_stations = int(city_data["num_stations"]) - self.num_lines = int(city_data["num_lines"] or "0") - self.num_light_lines = int(city_data["num_light_lines"] or "0") - self.num_interchanges = int(city_data["num_interchanges"] or "0") + self.try_fill_int_attribute(city_data, "num_stations") + self.try_fill_int_attribute(city_data, "num_lines", "0") + self.try_fill_int_attribute(city_data, "num_light_lines", "0") + self.try_fill_int_attribute(city_data, "num_interchanges", "0") else: - self.num_tram_lines = int(city_data["num_tram_lines"] or "0") - self.num_trolleybus_lines = int( - city_data["num_trolleybus_lines"] or "0" - ) - self.num_bus_lines = int(city_data["num_bus_lines"] or "0") - self.num_other_lines = int(city_data["num_other_lines"] or "0") + self.try_fill_int_attribute(city_data, "num_tram_lines", "0") + self.try_fill_int_attribute(city_data, "num_trolleybus_lines", "0") + self.try_fill_int_attribute(city_data, "num_bus_lines", "0") + self.try_fill_int_attribute(city_data, "num_other_lines", "0") - # Aquiring list of networks and modes + # Acquiring list of networks and modes networks = ( None if not city_data["networks"] @@ -1574,6 +1577,33 @@ class City: self.stops_and_platforms = set() # Set of stops and platforms el_id self.recovery_data = None + def try_fill_int_attribute( + self, city_data: dict, attr: str, default: str | None = None + ) -> None: + """Try to convert string value to int. Conversion is considered + to fail if one of the following is true: + * attr is not empty and data type casting fails; + * attr is empty and no default value is given. + In such cases the city is marked as bad by adding an error + to the city validation log. + """ + attr_value = city_data[attr] + if not attr_value and default is not None: + attr_value = default + + try: + attr_int = int(attr_value) + except ValueError: + print_value = ( + f"{city_data[attr]}" if city_data[attr] else "<empty>" + ) + self.error( + f"Configuration error: wrong value for {attr}: {print_value}" + ) + setattr(self, attr, 0) + else: + setattr(self, attr, attr_int) + @staticmethod def log_message(message, el): if el: @@ -1814,12 +1844,12 @@ class City: if not self.overground: result.update( { - "subwayl_expected": self.num_lines, - "lightrl_expected": self.num_light_lines, + "subwayl_expected": getattr(self, "num_lines", 0), + "lightrl_expected": getattr(self, "num_light_lines", 0), "subwayl_found": getattr(self, "found_lines", 0), "lightrl_found": getattr(self, "found_light_lines", 0), - "stations_expected": self.num_stations, - "transfers_expected": self.num_interchanges, + "stations_expected": getattr(self, "num_stations", 0), + "transfers_expected": getattr(self, "num_interchanges", 0), } ) else: @@ -1827,10 +1857,12 @@ class City: { "stations_expected": 0, "transfers_expected": 0, - "busl_expected": self.num_bus_lines, - "trolleybusl_expected": self.num_trolleybus_lines, - "traml_expected": self.num_tram_lines, - "otherl_expected": self.num_other_lines, + "busl_expected": getattr(self, "num_bus_lines", 0), + "trolleybusl_expected": getattr( + self, "num_trolleybus_lines", 0 + ), + "traml_expected": getattr(self, "num_tram_lines", 0), + "otherl_expected": getattr(self, "num_other_lines", 0), "busl_found": getattr(self, "found_bus_lines", 0), "trolleybusl_found": getattr( self, "found_trolleybus_lines", 0 @@ -2006,7 +2038,8 @@ class City: ) log_function = ( self.error - if not ( + if self.num_stations > 0 + and not ( 0 <= (self.num_stations - self.found_stations) / self.num_stations diff --git a/tests/assets/networks_with_bad_values.csv b/tests/assets/networks_with_bad_values.csv new file mode 100644 index 0000000..0d0b528 --- /dev/null +++ b/tests/assets/networks_with_bad_values.csv @@ -0,0 +1,12 @@ +#,City,Country,Region,Stations,Subway Lines,Light Rail +Monorail,Interchanges,"BBox (lon, lat)",Networks (opt.),Comment,Source, +291,Moscow,Russia,Europe,335,14,3,66,,"subway,train:Московский метрополитен;МЦК;МЦД","No bbox - skip the city", +,Moscow - Aeroexpress,Russia,Europe,22,0,3,0,"37.170,55.385,38.028,56.022",train:Аэроэкспресс,"No id - skip the city",https://aeroexpress.ru +292,Nizhny Novgorod,Russia,Europe,16,2,0,,"43.759918,56.1662,44.13208,56.410862",,"No configuration errors",,, +NBS,Novosibirsk,Russia,Europe,13,2,0,1,"82.774773,54.926747,83.059044,55.127864",,"Non-numeric ID",,, +294,Saint Petersburg,Russia,Europe,72,,,,"30.0648,59.7509,30.5976,60.1292",,"Empty line counts - no problem at CSV parsing stage",, +,,,,,,,,,,,, +295,Samara,Russia,Europe,10x,1,0,0,"50.011826,53.094024,50.411453,53.384147",,"Non-numeric station count",, +296,Volgograd,Russia,Europe,40,zero,2zero,0,"44.366704,48.636024,44.62302,48.81765",,"Non-numbers in subway and light_rail line counts",, +297,Yekaterinburg,Russia,Europe,,1,0,0,"60.460854,56.730505,60.727272,56.920997",,"Empty station count",, +,,,,,,,,,,,, + diff --git a/tests/sample_data.py b/tests/sample_data_for_build_tracks.py similarity index 99% rename from tests/sample_data.py rename to tests/sample_data_for_build_tracks.py index 0fffacd..ed1b589 100644 --- a/tests/sample_data.py +++ b/tests/sample_data_for_build_tracks.py @@ -37,7 +37,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 2, + "num_stations": 2, "tracks": [], "extended_tracks": [ (0.0, 0.0), @@ -100,7 +100,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 2, + "num_stations": 2, "tracks": [ (0.0, 0.0), (1.0, 0.0), @@ -190,7 +190,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 6, + "num_stations": 6, "tracks": [], "extended_tracks": [ (0.0, 0.0), @@ -289,7 +289,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 6, + "num_stations": 6, "tracks": [ (0.0, 0.0), (1.0, 0.0), @@ -401,7 +401,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 6, + "num_stations": 6, "tracks": [ (0.0, 0.0), (1.0, 0.0), @@ -511,7 +511,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 6, + "num_stations": 6, "tracks": [ (1.0, 0.0), (2.0, 0.0), @@ -620,7 +620,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 6, + "num_stations": 6, "tracks": [ (1.0, 0.0), (2.0, 0.0), @@ -725,7 +725,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 6, + "num_stations": 6, "tracks": [ (0.0, 0.0), (1.0, 0.0), @@ -826,7 +826,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 6, + "num_stations": 6, "tracks": [ (4.0, 0.0), (5.0, 0.0), @@ -937,7 +937,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 6, + "num_stations": 6, "tracks": [ (-1.0, 0.0), (0.0, 0.0), @@ -1069,7 +1069,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 6, + "num_stations": 6, "tracks": [ (-1.0, 0.0), (0.0, 0.0), @@ -1189,7 +1189,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 6, + "num_stations": 6, "tracks": [ (-1.0, 0.0), (6.0, 0.0), @@ -1304,7 +1304,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 6, + "num_stations": 6, "tracks": [ (1.0, 0.0), (4.0, 0.0), @@ -1377,7 +1377,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 4, + "num_stations": 4, "tracks": [], "extended_tracks": [ (0.0, 0.0), @@ -1455,7 +1455,7 @@ sample_networks = { </relation> </osm> """, - "station_count": 4, + "num_stations": 4, "tracks": [ (0.0, 0.0), (0.0, 1.0), diff --git a/tests/sample_data_for_error_messages.py b/tests/sample_data_for_error_messages.py new file mode 100644 index 0000000..9d5c5fc --- /dev/null +++ b/tests/sample_data_for_error_messages.py @@ -0,0 +1,316 @@ +sample_networks = { + "No errors": { + "xml": """<?xml version='1.0' encoding='UTF-8'?> +<osm version='0.6' generator='JOSM'> + <node id='1' version='1' lat='0.0' lon='0.0'> + <tag k='name' v='Station 1' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <node id='2' version='1' lat='0.0' lon='1.0'> + <tag k='name' v='Station 2' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <relation id='1' version='1'> + <member type='node' ref='1' role='' /> + <member type='node' ref='2' role='' /> + <tag k='name' v='Forward' /> + <tag k='ref' v='1' /> + <tag k='route' v='subway' /> + <tag k='type' v='route' /> + </relation> + <relation id='2' version='1'> + <member type='node' ref='2' role='' /> + <member type='node' ref='1' role='' /> + <tag k='name' v='Backward' /> + <tag k='ref' v='1' /> + <tag k='route' v='subway' /> + <tag k='type' v='route' /> + </relation> + <relation id='3' version='1'> + <member type='relation' ref='1' role='' /> + <member type='relation' ref='2' role='' /> + <tag k='ref' v='1' /> + <tag k='colour' v='red' /> + <tag k='route_master' v='subway' /> + <tag k='type' v='route_master' /> + </relation> +</osm> +""", + "num_stations": 2, + "num_lines": 1, + "num_light_lines": 0, + "num_interchanges": 0, + "errors": [], + "warnings": [], + "notices": [], + }, + "Bad station order": { + "xml": """<?xml version='1.0' encoding='UTF-8'?> +<osm version='0.6' generator='JOSM'> + <node id='1' version='1' lat='0.0' lon='0.0'> + <tag k='name' v='Station 1' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <node id='2' version='1' lat='0.0' lon='1.0'> + <tag k='name' v='Station 2' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <node id='3' version='1' lat='0.0' lon='2.0'> + <tag k='name' v='Station 3' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <node id='4' version='1' lat='0.0' lon='3.0'> + <tag k='name' v='Station 4' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <relation id='1' version='1'> + <member type='node' ref='1' role='' /> + <member type='node' ref='3' role='' /> + <member type='node' ref='2' role='' /> + <member type='node' ref='4' role='' /> + <tag k='name' v='Forward' /> + <tag k='ref' v='1' /> + <tag k='route' v='subway' /> + <tag k='type' v='route' /> + </relation> + <relation id='2' version='1'> + <member type='node' ref='4' role='' /> + <member type='node' ref='3' role='' /> + <member type='node' ref='2' role='' /> + <member type='node' ref='1' role='' /> + <tag k='name' v='Backward' /> + <tag k='ref' v='1' /> + <tag k='route' v='subway' /> + <tag k='type' v='route' /> + </relation> + <relation id='3' version='1'> + <member type='relation' ref='1' role='' /> + <member type='relation' ref='2' role='' /> + <tag k='ref' v='1' /> + <tag k='colour' v='red' /> + <tag k='route_master' v='subway' /> + <tag k='type' v='route_master' /> + </relation> +</osm> +""", + "num_stations": 4, + "num_lines": 1, + "num_light_lines": 0, + "num_interchanges": 0, + "errors": [ + 'Angle between stops around "Station 3" (2.0, 0.0) ' + 'is too narrow, 0 degrees (relation 1, "Forward")', + 'Angle between stops around "Station 2" (1.0, 0.0) ' + 'is too narrow, 0 degrees (relation 1, "Forward")', + ], + "warnings": [], + "notices": [], + }, + "Angle < 20 degrees": { + "xml": """<?xml version='1.0' encoding='UTF-8'?> +<osm version='0.6' generator='JOSM'> + <node id='1' version='1' lat='0.0' lon='0.0'> + <tag k='name' v='Station 1' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <node id='2' version='1' lat='0.0' lon='1.0'> + <tag k='name' v='Station 2' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <node id='3' version='1' lat='0.2' lon='0.0'> + <tag k='name' v='Station 3' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <relation id='1' version='1'> + <member type='node' ref='1' role='' /> + <member type='node' ref='2' role='' /> + <member type='node' ref='3' role='' /> + <tag k='name' v='Forward' /> + <tag k='ref' v='1' /> + <tag k='route' v='subway' /> + <tag k='type' v='route' /> + </relation> + <relation id='2' version='1'> + <member type='node' ref='3' role='' /> + <member type='node' ref='2' role='' /> + <member type='node' ref='1' role='' /> + <member type='way' ref='1' role='' /> + <tag k='name' v='Backward' /> + <tag k='ref' v='1' /> + <tag k='route' v='subway' /> + <tag k='type' v='route' /> + </relation> + <relation id='3' version='1'> + <member type='relation' ref='1' role='' /> + <member type='relation' ref='2' role='' /> + <tag k='ref' v='1' /> + <tag k='colour' v='red' /> + <tag k='route_master' v='subway' /> + <tag k='type' v='route_master' /> + </relation> +</osm> +""", + "num_stations": 3, + "num_lines": 1, + "num_light_lines": 0, + "num_interchanges": 0, + "errors": [ + 'Angle between stops around "Station 2" (1.0, 0.0) ' + 'is too narrow, 11 degrees (relation 1, "Forward")', + 'Angle between stops around "Station 2" (1.0, 0.0) ' + 'is too narrow, 11 degrees (relation 2, "Backward")', + ], + "warnings": [], + "notices": [], + }, + "Angle between 20 and 45 degrees": { + "xml": """<?xml version='1.0' encoding='UTF-8'?> +<osm version='0.6' generator='JOSM'> + <node id='1' version='1' lat='0.0' lon='0.0'> + <tag k='name' v='Station 1' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <node id='2' version='1' lat='0.0' lon='1.0'> + <tag k='name' v='Station 2' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <node id='3' version='1' lat='0.5' lon='0.0'> + <tag k='name' v='Station 3' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <relation id='1' version='1'> + <member type='node' ref='1' role='' /> + <member type='node' ref='2' role='' /> + <member type='node' ref='3' role='' /> + <tag k='name' v='Forward' /> + <tag k='ref' v='1' /> + <tag k='route' v='subway' /> + <tag k='type' v='route' /> + </relation> + <relation id='2' version='1'> + <member type='node' ref='3' role='' /> + <member type='node' ref='2' role='' /> + <member type='node' ref='1' role='' /> + <member type='way' ref='1' role='' /> + <tag k='name' v='Backward' /> + <tag k='ref' v='1' /> + <tag k='route' v='subway' /> + <tag k='type' v='route' /> + </relation> + <relation id='3' version='1'> + <member type='relation' ref='1' role='' /> + <member type='relation' ref='2' role='' /> + <tag k='ref' v='1' /> + <tag k='colour' v='red' /> + <tag k='route_master' v='subway' /> + <tag k='type' v='route_master' /> + </relation> +</osm> +""", + "num_stations": 3, + "num_lines": 1, + "num_light_lines": 0, + "num_interchanges": 0, + "errors": [], + "warnings": [], + "notices": [ + 'Angle between stops around "Station 2" (1.0, 0.0) ' + 'is too narrow, 27 degrees (relation 1, "Forward")', + 'Angle between stops around "Station 2" (1.0, 0.0) ' + 'is too narrow, 27 degrees (relation 2, "Backward")', + ], + }, + "Stops unordered along tracks provided each angle > 45 degrees": { + "xml": """<?xml version='1.0' encoding='UTF-8'?> +<osm version='0.6' generator='JOSM'> + <node id='1' version='1' lat='0.0' lon='0.0'> + <tag k='name' v='Station 1' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <node id='2' version='1' lat='0.0' lon='1.0'> + <tag k='name' v='Station 2' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <node id='3' version='1' lat='0.5' lon='0.0'> + <tag k='name' v='Station 3' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <node id='4' version='1' lat='1.0' lon='1.0'> + <tag k='name' v='Station 4' /> + <tag k='railway' v='station' /> + <tag k='station' v='subway' /> + </node> + <way id='1' version='1'> + <nd ref='1' /> + <nd ref='2' /> + <nd ref='3' /> + <tag k='railway' v='subway' /> + </way> + <way id='2' version='1'> + <nd ref='3' /> + <nd ref='4' /> + <tag k='railway' v='subway' /> + </way> + <relation id='1' version='1'> + <member type='node' ref='1' role='' /> + <member type='node' ref='3' role='' /> + <member type='node' ref='2' role='' /> + <member type='node' ref='4' role='' /> + <member type='way' ref='1' role='' /> + <member type='way' ref='2' role='' /> + <tag k='name' v='Forward' /> + <tag k='ref' v='1' /> + <tag k='route' v='subway' /> + <tag k='type' v='route' /> + </relation> + <relation id='2' version='1'> + <member type='node' ref='4' role='' /> + <member type='node' ref='2' role='' /> + <member type='node' ref='3' role='' /> + <member type='node' ref='1' role='' /> + <member type='way' ref='2' role='' /> + <member type='way' ref='1' role='' /> + <tag k='name' v='Backward' /> + <tag k='ref' v='1' /> + <tag k='route' v='subway' /> + <tag k='type' v='route' /> + </relation> + <relation id='3' version='1'> + <member type='relation' ref='1' role='' /> + <member type='relation' ref='2' role='' /> + <tag k='ref' v='1' /> + <tag k='colour' v='red' /> + <tag k='route_master' v='subway' /> + <tag k='type' v='route_master' /> + </relation> +</osm> +""", + "num_stations": 4, + "num_lines": 1, + "num_light_lines": 0, + "num_interchanges": 0, + "errors": [ + 'Stops on tracks are unordered near "Station 2" (1.0, 0.0) ' + '(relation 1, "Forward")', + 'Stops on tracks are unordered near "Station 3" (0.0, 0.5) ' + '(relation 2, "Backward")', + ], + "warnings": [], + "notices": [], + }, +} diff --git a/tests/test_build_tracks.py b/tests/test_build_tracks.py index da16780..14ea86b 100644 --- a/tests/test_build_tracks.py +++ b/tests/test_build_tracks.py @@ -9,52 +9,16 @@ or simply > python -m unittest """ -import io -import unittest -from subway_structure import City -from subway_io import load_xml -from tests.sample_data import sample_networks +from tests.sample_data_for_build_tracks import sample_networks +from tests.util import TestCase -class TestOneRouteTracks(unittest.TestCase): +class TestOneRouteTracks(TestCase): """Test tracks extending and truncating on one-route networks""" - CITY_TEMPLATE = { - "id": 1, - "name": "Null Island", - "country": "World", - "continent": "Africa", - "num_stations": None, # Would be taken from the sample network data - "num_lines": 1, - "num_light_lines": 0, - "num_interchanges": 0, - "bbox": "-179, -89, 179, 89", - "networks": "", - } - - def assertListAlmostEqual(self, list1, list2, places=10) -> None: - if not (isinstance(list1, list) and isinstance(list2, list)): - raise RuntimeError( - f"Not lists passed to the '{self.__class__.__name__}." - "assertListAlmostEqual' method" - ) - self.assertEqual(len(list1), len(list2)) - for a, b in zip(list1, list2): - if isinstance(a, list) and isinstance(b, list): - self.assertListAlmostEqual(a, b, places) - else: - self.assertAlmostEqual(a, b, places) - def prepare_city_routes(self, network) -> tuple: - city_data = self.CITY_TEMPLATE.copy() - city_data["num_stations"] = network["station_count"] - city = City(city_data) - elements = load_xml(io.BytesIO(network["xml"].encode("utf-8"))) - for el in elements: - city.add(el) - city.extract_routes() - city.validate() + city = self.validate_city(network) self.assertTrue(city.is_good) diff --git a/tests/test_error_messages.py b/tests/test_error_messages.py new file mode 100644 index 0000000..12a5583 --- /dev/null +++ b/tests/test_error_messages.py @@ -0,0 +1,22 @@ +from tests.sample_data_for_error_messages import sample_networks +from tests.util import TestCase + + +class TestValidationMessages(TestCase): + """Test that the validator provides expected validation messages + on different types of errors in input OSM data. + """ + + def _test_validation_messages_for_network(self, network_data): + city = self.validate_city(network_data) + + for err_level in ("errors", "warnings", "notices"): + self.assertListEqual( + sorted(getattr(city, err_level)), + sorted(network_data[err_level]), + ) + + def test_validation_messages(self) -> None: + for network_name, network_data in sample_networks.items(): + with self.subTest(msg=network_name): + self._test_validation_messages_for_network(network_data) diff --git a/tests/test_prepare_cities.py b/tests/test_prepare_cities.py new file mode 100644 index 0000000..e74505f --- /dev/null +++ b/tests/test_prepare_cities.py @@ -0,0 +1,36 @@ +import inspect +from pathlib import Path +from unittest import TestCase + +from process_subways import prepare_cities + + +class TestPrepareCities(TestCase): + def test_prepare_cities(self) -> None: + csv_path = ( + Path(inspect.getfile(self.__class__)).parent + / "assets" + / "networks_with_bad_values.csv" + ) + + cities = prepare_cities(cities_info_url=f"file://{csv_path}") + + city_errors = {city.name: sorted(city.errors) for city in cities} + + expected_errors = { + "Nizhny Novgorod": [], + "Novosibirsk": ["Configuration error: wrong value for id: NBS"], + "Saint Petersburg": [], + "Samara": [ + "Configuration error: wrong value for num_stations: 10x" + ], + "Volgograd": [ + "Configuration error: wrong value for num_light_lines: 2zero", + "Configuration error: wrong value for num_lines: zero", + ], + "Yekaterinburg": [ + "Configuration error: wrong value for num_stations: <empty>" + ], + } + + self.assertDictEqual(city_errors, expected_errors) diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 0000000..efab8c2 --- /dev/null +++ b/tests/util.py @@ -0,0 +1,49 @@ +import io +from unittest import TestCase as unittestTestCase + +from subway_io import load_xml +from subway_structure import City + + +class TestCase(unittestTestCase): + """TestCase class for testing the Subway Validator""" + + CITY_TEMPLATE = { + "id": 1, + "name": "Null Island", + "country": "World", + "continent": "Africa", + "bbox": "-179, -89, 179, 89", + "networks": "", + "num_stations": None, + "num_lines": 1, + "num_light_lines": 0, + "num_interchanges": 0, + } + + def validate_city(self, network) -> City: + city_data = self.CITY_TEMPLATE.copy() + for attr in self.CITY_TEMPLATE.keys(): + if attr in network: + city_data[attr] = network[attr] + + city = City(city_data) + elements = load_xml(io.BytesIO(network["xml"].encode("utf-8"))) + for el in elements: + city.add(el) + city.extract_routes() + city.validate() + return city + + def assertListAlmostEqual(self, list1, list2, places=10) -> None: + if not (isinstance(list1, list) and isinstance(list2, list)): + raise RuntimeError( + f"Not lists passed to the '{self.__class__.__name__}." + "assertListAlmostEqual' method" + ) + self.assertEqual(len(list1), len(list2)) + for a, b in zip(list1, list2): + if isinstance(a, list) and isinstance(b, list): + self.assertListAlmostEqual(a, b, places) + else: + self.assertAlmostEqual(a, b, places)