From 65bf6dbff71ecafa337ba318528bf353dfcf5623 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Wed, 31 Aug 2016 08:46:48 +0200 Subject: [PATCH 01/29] environment for behave tests --- test/behave/environment.py | 164 +++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 test/behave/environment.py diff --git a/test/behave/environment.py b/test/behave/environment.py new file mode 100644 index 00000000..10cb369f --- /dev/null +++ b/test/behave/environment.py @@ -0,0 +1,164 @@ +from behave import * +import logging +import os +import psycopg2 +import psycopg2.extras +import subprocess +from sys import version_info as python_version + +logger = logging.getLogger(__name__) + +userconfig = { + 'BASEURL' : 'http://localhost/nominatim', + 'BUILDDIR' : '../build', + 'REMOVE_TEMPLATE' : False, + 'KEEP_TEST_DB' : False, + 'TEMPLATE_DB' : 'test_template_nominatim', + 'TEST_DB' : 'test_nominatim', + 'TEST_SETTINGS_FILE' : '/tmp/nominatim_settings.php' +} + +class NominatimEnvironment(object): + """ Collects all functions for the execution of Nominatim functions. + """ + + def __init__(self, config): + self.build_dir = os.path.abspath(config['BUILDDIR']) + self.template_db = config['TEMPLATE_DB'] + self.test_db = config['TEST_DB'] + self.local_settings_file = config['TEST_SETTINGS_FILE'] + self.reuse_template = not config['REMOVE_TEMPLATE'] + self.keep_scenario_db = config['KEEP_TEST_DB'] + os.environ['NOMINATIM_SETTINGS'] = self.local_settings_file + + self.template_db_done = False + + def write_nominatim_config(self, dbname): + f = open(self.local_settings_file, 'w') + f.write("') + logger.debug("running osm2pgsql for template: %s\n%s\n%s" % (osm2pgsql, outstr, errstr)) + self.run_setup_script('create-functions', 'create-tables', + 'create-partition-tables', 'create-partition-functions', + 'load-data', 'create-search-indices') + + + + def setup_db(self, context): + self.setup_template_db() + self.write_nominatim_config(self.test_db) + conn = psycopg2.connect(database=self.template_db) + conn.set_isolation_level(0) + cur = conn.cursor() + cur.execute('DROP DATABASE IF EXISTS %s' % (self.test_db, )) + cur.execute('CREATE DATABASE %s TEMPLATE = %s' % (self.test_db, self.template_db)) + conn.close() + context.db = psycopg2.connect(database=self.test_db) + if python_version[0] < 3: + psycopg2.extras.register_hstore(context.db, globally=False, unicode=True) + else: + psycopg2.extras.register_hstore(context.db, globally=False) + + def teardown_db(self, context): + if 'db' in context: + context.db.close() + + if not self.keep_scenario_db: + self.db_drop_database(self.test_db) + + def run_setup_script(self, *args): + self.run_nominatim_script('setup', *args) + + def run_nominatim_script(self, script, *args): + cmd = [os.path.join(self.build_dir, 'utils', '%s.php' % script)] + cmd.extend(['--%s' % x for x in args]) + proc = subprocess.Popen(cmd, cwd=self.build_dir, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + (outp, outerr) = proc.communicate() + logger.debug("run_nominatim_script: %s\n%s\n%s" % (cmd, outp, outerr)) + assert (proc.returncode == 0), "Script '%s' failed:\n%s\n%s\n" % (script, outp, outerr) + + +class OSMDataFactory(object): + + def __init__(self): + scriptpath = os.path.dirname(os.path.abspath(__file__)) + self.scene_path = os.environ.get('SCENE_PATH', + os.path.join(scriptpath, '..', 'scenes', 'data')) + + +def before_all(context): + for k,v in userconfig.items(): + context.config.userdata.setdefault(k, v) + print('config:', context.config.userdata) + # logging setup + context.config.setup_logging() + # Nominatim test setup + context.nominatim = NominatimEnvironment(context.config.userdata) + context.osm = OSMDataFactory() + +def after_all(context): + context.nominatim.cleanup() + + +def before_scenario(context, scenario): + if 'DB' in context.tags: + context.nominatim.setup_db(context) + +def after_scenario(context, scenario): + if 'DB' in context.tags: + context.nominatim.teardown_db(context) + From c56c09e2c03a4e48116aef06fad005a4f21b98d7 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Mon, 7 Nov 2016 21:16:28 +0100 Subject: [PATCH 02/29] rename and add basic tests --- test/bdd/db/import/simple.feature | 20 ++++++++ test/bdd/db/test.feature | 1 + test/{behave => bdd}/environment.py | 18 +++++-- test/bdd/steps/db_ops.py | 75 +++++++++++++++++++++++++++++ test/bdd/steps/queries.py | 38 +++++++++++++++ 5 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 test/bdd/db/import/simple.feature create mode 100644 test/bdd/db/test.feature rename test/{behave => bdd}/environment.py (94%) create mode 100644 test/bdd/steps/db_ops.py create mode 100644 test/bdd/steps/queries.py diff --git a/test/bdd/db/import/simple.feature b/test/bdd/db/import/simple.feature new file mode 100644 index 00000000..043b27c5 --- /dev/null +++ b/test/bdd/db/import/simple.feature @@ -0,0 +1,20 @@ +@DB +Feature: Import of simple objects + Testing simple stuff + + @wip + Scenario: Import place node + Given the places + | osm | class | type | name | geometry | + | N1 | place | village | 'name' : 'Foo' | 10.0 -10.0 | + And the named places + | osm | class | type | housenumber | + | N2 | place | village | | + When importing + Then table placex contains + | object | class | type | name | centroid | + | N1 | place | village | 'name' : 'Foo' | 10.0,-10.0 +- 1m | + When sending query "Foo" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | diff --git a/test/bdd/db/test.feature b/test/bdd/db/test.feature new file mode 100644 index 00000000..6d42f744 --- /dev/null +++ b/test/bdd/db/test.feature @@ -0,0 +1 @@ +Feature: Test diff --git a/test/behave/environment.py b/test/bdd/environment.py similarity index 94% rename from test/behave/environment.py rename to test/bdd/environment.py index 10cb369f..3ce3c83a 100644 --- a/test/behave/environment.py +++ b/test/bdd/environment.py @@ -18,6 +18,8 @@ userconfig = { 'TEST_SETTINGS_FILE' : '/tmp/nominatim_settings.php' } +use_step_matcher("re") + class NominatimEnvironment(object): """ Collects all functions for the execution of Nominatim functions. """ @@ -139,13 +141,23 @@ class OSMDataFactory(object): self.scene_path = os.environ.get('SCENE_PATH', os.path.join(scriptpath, '..', 'scenes', 'data')) + def make_geometry(self, geom): + if geom.find(',') < 0: + return 'POINT(%s)' % geom + + if geom.find('(') < 0: + return 'LINESTRING(%s)' % geom + + return 'POLYGON(%s)' % geom + def before_all(context): - for k,v in userconfig.items(): - context.config.userdata.setdefault(k, v) - print('config:', context.config.userdata) # logging setup context.config.setup_logging() + # set up -D options + for k,v in userconfig.items(): + context.config.userdata.setdefault(k, v) + logging.debug('User config: %s' %(str(context.config.userdata))) # Nominatim test setup context.nominatim = NominatimEnvironment(context.config.userdata) context.osm = OSMDataFactory() diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py new file mode 100644 index 00000000..c2d5a9fb --- /dev/null +++ b/test/bdd/steps/db_ops.py @@ -0,0 +1,75 @@ +import base64 +import random +import string + +def _format_placex_columns(row, force_name): + out = { + 'osm_type' : row['osm'][0], + 'osm_id' : row['osm'][1:], + 'admin_level' : row.get('admin_level', 100) + } + + for k in ('class', 'type', 'housenumber', 'street', + 'addr_place', 'isin', 'postcode', 'country_code'): + if k in row.headings and row[k]: + out[k] = row[k] + + if 'name' in row.headings: + if row['name'].startswith("'"): + out['name'] = eval('{' + row['name'] + '}') + else: + out['name'] = { 'name' : row['name'] } + elif force_name: + out['name'] = { 'name' : ''.join(random.choice(string.printable) for _ in range(int(random.random()*30))) } + + if 'extratags' in row.headings: + out['extratags'] = eval('{%s}' % row['extratags']) + + return out + + +@given("the (?Pnamed )?places") +def add_data_to_place_table(context, named): + cur = context.db.cursor() + cur.execute('ALTER TABLE place DISABLE TRIGGER place_before_insert') + for r in context.table: + cols = _format_placex_columns(r, named is not None) + + if 'geometry' in r.headings: + geometry = "'%s'::geometry" % context.osm.make_geometry(r['geometry']) + elif cols['osm_type'] == 'N': + geometry = "ST_Point(%f, %f)" % (random.random()*360 - 180, random.random()*180 - 90) + else: + raise RuntimeError("Missing geometry for place") + + query = 'INSERT INTO place (%s, geometry) values(%s, ST_SetSRID(%s, 4326))' % ( + ','.join(cols.keys()), + ','.join(['%s' for x in range(len(cols))]), + geometry + ) + cur.execute(query, list(cols.values())) + cur.execute('ALTER TABLE place ENABLE TRIGGER place_before_insert') + cur.close() + context.db.commit() + + +@when("importing") +def import_and_index_data_from_place_table(context): + context.nominatim.run_setup_script('create-functions', 'create-partition-functions') + cur = context.db.cursor() + cur.execute( + """insert into placex (osm_type, osm_id, class, type, name, admin_level, + housenumber, street, addr_place, isin, postcode, country_code, extratags, + geometry) + select * from place where not (class='place' and type='houses' and osm_type='W')""") + cur.execute( + """select insert_osmline (osm_id, housenumber, street, addr_place, + postcode, country_code, geometry) + from place where class='place' and type='houses' and osm_type='W'""") + context.db.commit() + context.nominatim.run_setup_script('index', 'index-noanalyse') + + +@then("table (?P\w+) contains(?P exactly)?") +def check_table_contents(context, table, exact): + pass diff --git a/test/bdd/steps/queries.py b/test/bdd/steps/queries.py new file mode 100644 index 00000000..d9187928 --- /dev/null +++ b/test/bdd/steps/queries.py @@ -0,0 +1,38 @@ +""" Steps that run search queries. + + Queries may either be run directly via PHP using the query script + or via the HTTP interface. +""" + +import os +import subprocess + +class SearchResponse(object): + + def __init__(response, + +@when(u'searching for "(?P.*)"( with params)?$') +def query_cmd(context, query): + """ Query directly via PHP script. + """ + cmd = [os.path.join(context.nominatim.build_dir, 'utils', 'query.php'), + '--search', query] + # add more parameters in table form + if context.table: + for h in context.table.headings: + value = context.table[0][h].strip() + if value: + cmd.extend(('--' + h, value)) + + proc = subprocess.Popen(cmd, cwd=context.nominatim.build_dir, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + (outp, err) = proc.communicate() + + assert_equals (0, proc.returncode), "query.php failed with message: %s" % err + + context. + world.page = outp + world.response_format = 'json' + world.request_type = 'search' + world.returncode = 200 + From 47f94c69882319862ef82038b2cb035012540858 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Sun, 20 Nov 2016 17:30:54 +0100 Subject: [PATCH 03/29] simple search steps --- test/bdd/db/import/search_simple.feature | 16 +++ test/bdd/db/import/simple.feature | 20 --- test/bdd/db/query/search_simple.feature | 13 ++ test/bdd/db/test.feature | 1 - test/bdd/environment.py | 51 ++++++- test/bdd/steps/db_ops.py | 168 ++++++++++++++++++----- test/bdd/steps/queries.py | 46 +++++-- test/bdd/steps/results.py | 33 +++++ 8 files changed, 277 insertions(+), 71 deletions(-) create mode 100644 test/bdd/db/import/search_simple.feature delete mode 100644 test/bdd/db/import/simple.feature create mode 100644 test/bdd/db/query/search_simple.feature delete mode 100644 test/bdd/db/test.feature create mode 100644 test/bdd/steps/results.py diff --git a/test/bdd/db/import/search_simple.feature b/test/bdd/db/import/search_simple.feature new file mode 100644 index 00000000..fb4071dd --- /dev/null +++ b/test/bdd/db/import/search_simple.feature @@ -0,0 +1,16 @@ +@DB +Feature: Import of simple objects + Testing simple stuff + + @wip + Scenario: Import place node + Given the places + | osm | class | type | name | name+ref | geometry | + | N1 | place | village | Foo | 32 | 10.0 -10.0 | + And the named places + | osm | class | type | housenr | + | N2 | place | village | | + When importing + Then placex contains + | object | class | type | name | name+ref | centroid*10 | + | N1 | place | village | Foo | 32 | 1 -1 | diff --git a/test/bdd/db/import/simple.feature b/test/bdd/db/import/simple.feature deleted file mode 100644 index 043b27c5..00000000 --- a/test/bdd/db/import/simple.feature +++ /dev/null @@ -1,20 +0,0 @@ -@DB -Feature: Import of simple objects - Testing simple stuff - - @wip - Scenario: Import place node - Given the places - | osm | class | type | name | geometry | - | N1 | place | village | 'name' : 'Foo' | 10.0 -10.0 | - And the named places - | osm | class | type | housenumber | - | N2 | place | village | | - When importing - Then table placex contains - | object | class | type | name | centroid | - | N1 | place | village | 'name' : 'Foo' | 10.0,-10.0 +- 1m | - When sending query "Foo" - Then results contain - | ID | osm_type | osm_id | - | 0 | N | 1 | diff --git a/test/bdd/db/query/search_simple.feature b/test/bdd/db/query/search_simple.feature new file mode 100644 index 00000000..f0c66f13 --- /dev/null +++ b/test/bdd/db/query/search_simple.feature @@ -0,0 +1,13 @@ +@DB +Feature: Searching of simple objects + Testing simple stuff + + Scenario: Search for place node + Given the places + | osm | class | type | name+name | geometry | + | N1 | place | village | Foo | 10.0 -10.0 | + When importing + And searching for "Foo" + Then results contain + | ID | osm | class | type | centroid | + | 0 | N1 | place | village | 10 -10 | diff --git a/test/bdd/db/test.feature b/test/bdd/db/test.feature deleted file mode 100644 index 6d42f744..00000000 --- a/test/bdd/db/test.feature +++ /dev/null @@ -1 +0,0 @@ -Feature: Test diff --git a/test/bdd/environment.py b/test/bdd/environment.py index 3ce3c83a..c878c61c 100644 --- a/test/bdd/environment.py +++ b/test/bdd/environment.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) userconfig = { 'BASEURL' : 'http://localhost/nominatim', - 'BUILDDIR' : '../build', + 'BUILDDIR' : os.path.join(os.path.split(__file__)[0], "../../build"), 'REMOVE_TEMPLATE' : False, 'KEEP_TEST_DB' : False, 'TEMPLATE_DB' : 'test_template_nominatim', @@ -140,15 +140,53 @@ class OSMDataFactory(object): scriptpath = os.path.dirname(os.path.abspath(__file__)) self.scene_path = os.environ.get('SCENE_PATH', os.path.join(scriptpath, '..', 'scenes', 'data')) + self.scene_cache = {} - def make_geometry(self, geom): + def parse_geometry(self, geom, scene): + if geom[0].find(':') >= 0: + out = self.get_scene_geometry(scene, geom[1:]) if geom.find(',') < 0: - return 'POINT(%s)' % geom + out = 'POINT(%s)' % geom + elif geom.find('(') < 0: + out = 'LINESTRING(%s)' % geom + else: + out = 'POLYGON(%s)' % geom - if geom.find('(') < 0: - return 'LINESTRING(%s)' % geom + # TODO parse precision + return out, 0 - return 'POLYGON(%s)' % geom + def get_scene_geometry(self, default_scene, name): + geoms = [] + defscene = self.load_scene(default_scene) + for obj in name.split('+'): + oname = obj.strip() + if oname.startswith(':'): + wkt = defscene[oname[1:]] + else: + scene, obj = oname.split(':', 2) + scene_geoms = world.load_scene(scene) + wkt = scene_geoms[obj] + + geoms.append("'%s'::geometry" % wkt) + + if len(geoms) == 1: + return geoms[0] + else: + return 'ST_LineMerge(ST_Collect(ARRAY[%s]))' % ','.join(geoms) + + def load_scene(self, name): + if name in self.scene_cache: + return self.scene_cache[name] + + scene = {} + with open(os.path.join(self.scene_path, "%s.wkt" % name), 'r') as fd: + for line in fd: + if line.strip(): + obj, wkt = line.split('|', 2) + scene[obj.strip()] = wkt.strip() + self.scene_cache[name] = scene + + return scene def before_all(context): @@ -169,6 +207,7 @@ def after_all(context): def before_scenario(context, scenario): if 'DB' in context.tags: context.nominatim.setup_db(context) + context.scene = None def after_scenario(context, scenario): if 'DB' in context.tags: diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py index c2d5a9fb..1bb2acaa 100644 --- a/test/bdd/steps/db_ops.py +++ b/test/bdd/steps/db_ops.py @@ -1,31 +1,108 @@ import base64 import random import string +import re +from nose.tools import * # for assert functions +import psycopg2.extras -def _format_placex_columns(row, force_name): - out = { - 'osm_type' : row['osm'][0], - 'osm_id' : row['osm'][1:], - 'admin_level' : row.get('admin_level', 100) - } +class PlaceColumn: - for k in ('class', 'type', 'housenumber', 'street', - 'addr_place', 'isin', 'postcode', 'country_code'): - if k in row.headings and row[k]: - out[k] = row[k] + def __init__(self, context, force_name): + self.columns = {} + self.force_name = force_name + self.context = context + self.geometry = None - if 'name' in row.headings: - if row['name'].startswith("'"): - out['name'] = eval('{' + row['name'] + '}') + def add(self, key, value): + if hasattr(self, 'set_key_' + key): + getattr(self, 'set_key_' + key)(value) + elif key.startswith('name+'): + self.add_hstore('name', key[5:], value) + elif key.startswith('extra+'): + self.add_hstore('extratags', key[6:], value) else: - out['name'] = { 'name' : row['name'] } - elif force_name: - out['name'] = { 'name' : ''.join(random.choice(string.printable) for _ in range(int(random.random()*30))) } + assert_in(key, ('class', 'type', 'street', 'addr_place', + 'isin', 'postcode')) + self.columns[key] = value - if 'extratags' in row.headings: - out['extratags'] = eval('{%s}' % row['extratags']) + def set_key_name(self, value): + self.add_hstore('name', 'name', value) - return out + def set_key_osm(self, value): + assert_in(value[0], 'NRW') + ok_(value[1:].isdigit()) + + self.columns['osm_type'] = value[0] + self.columns['osm_id'] = int(value[1:]) + + def set_key_admin(self, value): + self.columns['admin_level'] = int(value) + + def set_key_housenr(self, value): + self.columns['housenumber'] = value + + def set_key_cc(self, value): + ok_(len(value) == 2) + self.columns['country_code'] = value + + def set_key_geometry(self, value): + geom, precision = self.context.osm.parse_geometry(value, self.context.scene) + assert_is_not_none(geom) + self.geometry = "ST_SetSRID('%s'::geometry, 4326)" % geom + + def add_hstore(self, column, key, value): + if column in self.columns: + self.columns[column][key] = value + else: + self.columns[column] = { key : value } + + def db_insert(self, cursor): + assert_in('osm_type', self.columns) + if self.force_name and 'name' not in self.columns: + self.add_hstore('name', 'name', ''.join(random.choice(string.printable) + for _ in range(int(random.random()*30)))) + + if self.columns['osm_type'] == 'N' and self.geometry is None: + self.geometry = "ST_SetSRID(ST_Point(%f, %f), 4326)" % ( + random.random()*360 - 180, random.random()*180 - 90) + + query = 'INSERT INTO place (%s, geometry) values(%s, %s)' % ( + ','.join(self.columns.keys()), + ','.join(['%s' for x in range(len(self.columns))]), + self.geometry) + cursor.execute(query, list(self.columns.values())) + +class NominatimID: + """ Splits a unique identifier for places into its components. + As place_ids cannot be used for testing, we use a unique + identifier instead that is of the form [:]. + """ + + id_regex = re.compile(r"(?P[NRW])(?P\d+)(?P:\w+)?") + + def __init__(self, oid): + self.typ = self.oid = self.cls = None + + if oid is not None: + m = self.id_regex.fullmatch(oid) + assert_is_not_none(m, "ID '%s' not of form [:]" % oid) + + self.typ = m.group('tp') + self.oid = m.group('id') + self.cls = m.group('cls') + + def table_select(self): + """ Return where clause and parameter list to select the object + from a Nominatim table. + """ + where = 'osm_type = %s and osm_id = %s' + params = [self.typ, self. oid] + + if self.cls is not None: + where += ' class = %s' + params.append(self.cls) + + return where, params @given("the (?Pnamed )?places") @@ -33,21 +110,12 @@ def add_data_to_place_table(context, named): cur = context.db.cursor() cur.execute('ALTER TABLE place DISABLE TRIGGER place_before_insert') for r in context.table: - cols = _format_placex_columns(r, named is not None) + col = PlaceColumn(context, named is not None) - if 'geometry' in r.headings: - geometry = "'%s'::geometry" % context.osm.make_geometry(r['geometry']) - elif cols['osm_type'] == 'N': - geometry = "ST_Point(%f, %f)" % (random.random()*360 - 180, random.random()*180 - 90) - else: - raise RuntimeError("Missing geometry for place") + for h in r.headings: + col.add(h, r[h]) - query = 'INSERT INTO place (%s, geometry) values(%s, ST_SetSRID(%s, 4326))' % ( - ','.join(cols.keys()), - ','.join(['%s' for x in range(len(cols))]), - geometry - ) - cur.execute(query, list(cols.values())) + col.db_insert(cur) cur.execute('ALTER TABLE place ENABLE TRIGGER place_before_insert') cur.close() context.db.commit() @@ -70,6 +138,36 @@ def import_and_index_data_from_place_table(context): context.nominatim.run_setup_script('index', 'index-noanalyse') -@then("table (?P
\w+) contains(?P exactly)?") -def check_table_contents(context, table, exact): - pass +@then("placex contains(?P exactly)?") +def check_placex_contents(context, exact): + cur = context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) + if exact: + cur.execute('SELECT osm_type, osm_id, class from placex') + to_match = [(r[0], r[1], r[2]) for r in cur] + + for row in context.table: + nid = NominatimID(row['object']) + where, params = nid.table_select() + cur.execute("""SELECT *, ST_AsText(geometry) as geomtxt, + ST_X(centroid) as cx, ST_Y(centroid) as cy + FROM placex where %s""" % where, + params) + + for res in cur: + for h in row.headings: + if h == 'object': + pass + elif h.startswith('name'): + name = h[5:] if h.startswith('name+') else 'name' + assert_in(name, res['name']) + eq_(res['name'][name], row[h]) + elif h.startswith('extratags+'): + eq_(res['extratags'][h[10:]], row[h]) + elif h.startswith('centroid'): + fac = float(h[9:]) if h.startswith('centroid*') else 1.0 + x, y = row[h].split(' ') + assert_almost_equal(float(x) * fac, res['cx']) + assert_almost_equal(float(y) * fac, res['cy']) + else: + eq_(row[h], str(res[h])) + context.db.commit() diff --git a/test/bdd/steps/queries.py b/test/bdd/steps/queries.py index d9187928..acb7ee91 100644 --- a/test/bdd/steps/queries.py +++ b/test/bdd/steps/queries.py @@ -4,14 +4,47 @@ or via the HTTP interface. """ +import json import os import subprocess +from collections import OrderedDict +from nose.tools import * # for assert functions class SearchResponse(object): - def __init__(response, + def __init__(self, page, fmt='json', errorcode=200): + self.page = page + self.format = fmt + self.errorcode = errorcode + getattr(self, 'parse_' + fmt)() -@when(u'searching for "(?P.*)"( with params)?$') + def parse_json(self): + self.result = json.JSONDecoder(object_pairs_hook=OrderedDict).decode(self.page) + + def match_row(self, row): + if 'ID' in row.headings: + todo = [int(row['ID'])] + else: + todo = range(len(self.result)) + + for i in todo: + res = self.result[i] + for h in row.headings: + if h == 'ID': + pass + elif h == 'osm': + assert_equal(res['osm_type'], row[h][0]) + assert_equal(res['osm_id'], row[h][1:]) + elif h == 'centroid': + x, y = row[h].split(' ') + assert_almost_equal(float(y), float(res['lat'])) + assert_almost_equal(float(x), float(res['lon'])) + else: + assert_in(h, res) + assert_equal(str(res[h]), str(row[h])) + + +@when(u'searching for "(?P.*)"') def query_cmd(context, query): """ Query directly via PHP script. """ @@ -28,11 +61,6 @@ def query_cmd(context, query): stdout=subprocess.PIPE, stderr=subprocess.PIPE) (outp, err) = proc.communicate() - assert_equals (0, proc.returncode), "query.php failed with message: %s" % err - - context. - world.page = outp - world.response_format = 'json' - world.request_type = 'search' - world.returncode = 200 + assert_equals (0, proc.returncode, "query.php failed with message: %s" % err) + context.response = SearchResponse(outp.decode('utf-8'), 'json') diff --git a/test/bdd/steps/results.py b/test/bdd/steps/results.py new file mode 100644 index 00000000..87fefd4a --- /dev/null +++ b/test/bdd/steps/results.py @@ -0,0 +1,33 @@ +""" Steps that check results. +""" + +from nose.tools import * # for assert functions + +def compare(operator, op1, op2): + if operator == 'less than': + return op1 < op2 + elif operator == 'more than': + return op1 > op2 + elif operator == 'exactly': + return op1 == op2 + elif operator == 'at least': + return op1 >= op2 + elif operator == 'at most': + return op1 <= op2 + else: + raise Exception("unknown operator '%s'" % operator) + +@step(u'(?Pless than|more than|exactly|at least|at most) (?P\d+) results? (?:is|are) returned') +def validate_result_number(context, operator, number): + numres = len(context.response.result) + ok_(compare(operator, numres, int(number)), + "Bad number of results: expected %s %s, got %d." % (operator, number, numres)) + + +@then(u'results contain') +def step_impl(context): + context.execute_steps("then at least 1 result is returned") + + for line in context.table: + context.response.match_row(line) + From c594644aa73db2c66577a5685390a9d7deb215b5 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Sun, 20 Nov 2016 20:03:51 +0100 Subject: [PATCH 04/29] add placex import tests --- test/bdd/db/import/placex.feature | 313 +++++++++++++++++++++++ test/bdd/db/import/search_simple.feature | 16 -- test/bdd/environment.py | 21 +- test/bdd/steps/db_ops.py | 28 +- 4 files changed, 345 insertions(+), 33 deletions(-) create mode 100644 test/bdd/db/import/placex.feature delete mode 100644 test/bdd/db/import/search_simple.feature diff --git a/test/bdd/db/import/placex.feature b/test/bdd/db/import/placex.feature new file mode 100644 index 00000000..7cbedaa3 --- /dev/null +++ b/test/bdd/db/import/placex.feature @@ -0,0 +1,313 @@ +@DB +Feature: Import into placex + Tests that data in placex is completed correctly. + + Scenario: No country code tag is available + Given the named places + | osm | class | type | geometry | + | N1 | highway | primary | country:us | + When importing + Then placex contains + | object | country_code | calculated_country_code | + | N1 | None | us | + + Scenario: Location overwrites country code tag + Given the named places + | osm | class | type | country | geometry | + | N1 | highway | primary | de | country:us | + When importing + Then placex contains + | object | country_code | calculated_country_code | + | N1 | de | us | + + Scenario: Country code tag overwrites location for countries + Given the named places + | osm | class | type | admin | country | geometry | + | R1 | boundary | administrative | 2 | de | (-100 40, -101 40, -101 41, -100 41, -100 40) | + When importing + Then placex contains + | object | country_code | calculated_country_code | + | R1 | de | de | + + Scenario: Illegal country code tag for countries is ignored + Given the named places + | osm | class | type | admin | country | geometry | + | R1 | boundary | administrative | 2 | xx | (-100 40, -101 40, -101 41, -100 41, -100 40) | + When importing + Then placex contains + | object | country_code | calculated_country_code | + | R1 | xx | us | + + Scenario: admin level is copied over + Given the named places + | osm | class | type | admin | + | N1 | place | state | 3 | + When importing + Then placex contains + | object | admin_level | + | N1 | 3 | + + Scenario: admin level is default 15 + Given the named places + | osm | class | type | + | N1 | amenity | prison | + When importing + Then placex contains + | object | admin_level | + | N1 | 15 | + + Scenario: admin level is never larger than 15 + Given the named places + | osm | class | type | admin | + | N1 | amenity | prison | 16 | + When importing + Then placex contains + | object | admin_level | + | N1 | 15 | + + Scenario: postcode node without postcode is dropped + Given the places + | osm | class | type | name+ref | + | N1 | place | postcode | 12334 | + When importing + Then placex has no entry for N1 + + Scenario: postcode boundary without postcode is dropped + Given the places + | osm | class | type | name+ref | geometry | + | R1 | boundary | postal_code | 554476 | poly-area:0.1 | + When importing + Then placex has no entry for R1 + + Scenario: search and address ranks for GB post codes correctly assigned + Given the places + | osm | class | type | postcode | geometry | + | N1 | place | postcode | E45 2CD | country:gb | + | N2 | place | postcode | E45 2 | country:gb | + | N3 | place | postcode | Y45 | country:gb | + When importing + Then placex contains + | object | postcode | calculated_country_code | rank_search | rank_address | + | N1 | E45 2CD | gb | 25 | 5 | + | N2 | E45 2 | gb | 23 | 5 | + | N3 | Y45 | gb | 21 | 5 | + + Scenario: wrongly formatted GB postcodes are down-ranked + Given the places + | osm | class | type | postcode | geometry | + | N1 | place | postcode | EA452CD | country:gb | + | N2 | place | postcode | E45 23 | country:gb | + | N3 | place | postcode | y45 | country:gb | + When importing + Then placex contains + | object | calculated_country_code | rank_search | rank_address | + | N1 | gb | 30 | 30 | + | N2 | gb | 30 | 30 | + | N3 | gb | 30 | 30 | + + Scenario: search and address rank for DE postcodes correctly assigned + Given the places + | osm | class | type | postcode | geometry | + | N1 | place | postcode | 56427 | country:de | + | N2 | place | postcode | 5642 | country:de | + | N3 | place | postcode | 5642A | country:de | + | N4 | place | postcode | 564276 | country:de | + When importing + Then placex contains + | object | calculated_country_code | rank_search | rank_address | + | N1 | de | 21 | 11 | + | N2 | de | 30 | 30 | + | N3 | de | 30 | 30 | + | N4 | de | 30 | 30 | + + Scenario: search and address rank for other postcodes are correctly assigned + Given the places + | osm | class | type | postcode | geometry | + | N1 | place | postcode | 1 | country:ca | + | N2 | place | postcode | X3 | country:ca | + | N3 | place | postcode | 543 | country:ca | + | N4 | place | postcode | 54dc | country:ca | + | N5 | place | postcode | 12345 | country:ca | + | N6 | place | postcode | 55TT667 | country:ca | + | N7 | place | postcode | 123-65 | country:ca | + | N8 | place | postcode | 12 445 4 | country:ca | + | N9 | place | postcode | A1:bc10 | country:ca | + When importing + Then placex contains + | object | calculated_country_code | rank_search | rank_address | + | N1 | ca | 21 | 11 | + | N2 | ca | 21 | 11 | + | N3 | ca | 21 | 11 | + | N4 | ca | 21 | 11 | + | N5 | ca | 21 | 11 | + | N6 | ca | 21 | 11 | + | N7 | ca | 25 | 11 | + | N8 | ca | 25 | 11 | + | N9 | ca | 25 | 11 | + + Scenario: search and address ranks for places are correctly assigned + Given the named places + | osm | class | type | + | N1 | foo | bar | + | N11 | place | Continent | + | N12 | place | continent | + | N13 | place | sea | + | N14 | place | country | + | N15 | place | state | + | N16 | place | region | + | N17 | place | county | + | N18 | place | city | + | N19 | place | island | + | N20 | place | town | + | N21 | place | village | + | N22 | place | hamlet | + | N23 | place | municipality | + | N24 | place | district | + | N25 | place | unincorporated_area | + | N26 | place | borough | + | N27 | place | suburb | + | N28 | place | croft | + | N29 | place | subdivision | + | N30 | place | isolated_dwelling | + | N31 | place | farm | + | N32 | place | locality | + | N33 | place | islet | + | N34 | place | mountain_pass | + | N35 | place | neighbourhood | + | N36 | place | house | + | N37 | place | building | + | N38 | place | houses | + And the named places + | osm | class | type | extra+locality | + | N100 | place | locality | townland | + And the named places + | osm | class | type | extra+capital | + | N101 | place | city | yes | + When importing + Then placex contains + | object | rank_search | rank_address | + | N1 | 30 | 30 | + | N11 | 30 | 30 | + | N12 | 2 | 2 | + | N13 | 2 | 0 | + | N14 | 4 | 4 | + | N15 | 8 | 8 | + | N16 | 18 | 0 | + | N17 | 12 | 12 | + | N18 | 16 | 16 | + | N19 | 17 | 0 | + | N20 | 18 | 16 | + | N21 | 19 | 16 | + | N22 | 19 | 16 | + | N23 | 19 | 16 | + | N24 | 19 | 16 | + | N25 | 19 | 16 | + | N26 | 19 | 16 | + | N27 | 20 | 20 | + | N28 | 20 | 20 | + | N29 | 20 | 20 | + | N30 | 20 | 20 | + | N31 | 20 | 0 | + | N32 | 20 | 0 | + | N33 | 20 | 0 | + | N34 | 20 | 0 | + | N100 | 20 | 20 | + | N101 | 15 | 16 | + | N35 | 22 | 22 | + | N36 | 30 | 30 | + | N37 | 30 | 30 | + | N38 | 28 | 0 | + + Scenario: search and address ranks for boundaries are correctly assigned + Given the named places + | osm | class | type | + | N1 | boundary | administrative | + And the named places + | osm | class | type | geometry | + | W10 | boundary | administrative | 10 10, 11 11 | + And the named places + | osm | class | type | admin | geometry | + | R20 | boundary | administrative | 2 | (1 1, 2 2, 1 2, 1 1) | + | R21 | boundary | administrative | 32 | (3 3, 4 4, 3 4, 3 3) | + | R22 | boundary | nature_park | 6 | (0 0, 1 0, 0 1, 0 0) | + | R23 | boundary | natural_reserve| 10 | (0 0, 1 1, 1 0, 0 0) | + When importing + Then placex has no entry for N1 + And placex has no entry for W10 + And placex contains + | object | rank_search | rank_address | + | R20 | 4 | 4 | + | R21 | 30 | 30 | + | R22 | 12 | 0 | + | R23 | 20 | 0 | + + Scenario: search and address ranks for highways correctly assigned + Given the scene roads-with-pois + And the places + | osm | class | type | + | N1 | highway | bus_stop | + And the places + | osm | class | type | geometry | + | W1 | highway | primary | :w-south | + | W2 | highway | secondary | :w-south | + | W3 | highway | tertiary | :w-south | + | W4 | highway | residential | :w-north | + | W5 | highway | unclassified | :w-north | + | W6 | highway | something | :w-north | + When importing + Then placex contains + | object | rank_search | rank_address | + | N1 | 30 | 30 | + | W1 | 26 | 26 | + | W2 | 26 | 26 | + | W3 | 26 | 26 | + | W4 | 26 | 26 | + | W5 | 26 | 26 | + | W6 | 26 | 26 | + + Scenario: rank and inclusion of landuses + Given the named places + | osm | class | type | + | N2 | landuse | residential | + And the named places + | osm | class | type | geometry | + | W2 | landuse | residential | 1 1, 1 1.1 | + | W4 | landuse | residential | poly-area:0.1 | + | R2 | landuse | residential | poly-area:0.05 | + | R3 | landuse | forrest | poly-area:0.5 | + When importing + Then placex contains + | object | rank_search | rank_address | + | N2 | 30 | 30 | + | W2 | 30 | 30 | + | W4 | 22 | 22 | + | R2 | 22 | 22 | + | R3 | 22 | 0 | + + Scenario: rank and inclusion of naturals + Given the named places + | osm | class | type | + | N2 | natural | peak | + | N4 | natural | volcano | + | N5 | natural | foobar | + And the named places + | osm | class | type | geometry | + | W2 | natural | mountain_range | 12 12,11 11 | + | W3 | natural | foobar | 13 13,13.1 13 | + | R3 | natural | volcano | poly-area:0.1 | + | R4 | natural | foobar | poly-area:0.5 | + | R5 | natural | sea | poly-area:5.0 | + | R6 | natural | sea | poly-area:0.01 | + When importing + Then placex contains + | object | rank_search | rank_address | + | N2 | 18 | 0 | + | N4 | 18 | 0 | + | N5 | 30 | 30 | + | W2 | 18 | 0 | + | R3 | 18 | 0 | + | R4 | 22 | 0 | + | R5 | 4 | 4 | + | R6 | 4 | 4 | + | W3 | 30 | 30 | + diff --git a/test/bdd/db/import/search_simple.feature b/test/bdd/db/import/search_simple.feature deleted file mode 100644 index fb4071dd..00000000 --- a/test/bdd/db/import/search_simple.feature +++ /dev/null @@ -1,16 +0,0 @@ -@DB -Feature: Import of simple objects - Testing simple stuff - - @wip - Scenario: Import place node - Given the places - | osm | class | type | name | name+ref | geometry | - | N1 | place | village | Foo | 32 | 10.0 -10.0 | - And the named places - | osm | class | type | housenr | - | N2 | place | village | | - When importing - Then placex contains - | object | class | type | name | name+ref | centroid*10 | - | N1 | place | village | Foo | 32 | 1 -1 | diff --git a/test/bdd/environment.py b/test/bdd/environment.py index c878c61c..a954b253 100644 --- a/test/bdd/environment.py +++ b/test/bdd/environment.py @@ -4,6 +4,7 @@ import os import psycopg2 import psycopg2.extras import subprocess +from nose.tools import * # for assert functions from sys import version_info as python_version logger = logging.getLogger(__name__) @@ -143,28 +144,28 @@ class OSMDataFactory(object): self.scene_cache = {} def parse_geometry(self, geom, scene): - if geom[0].find(':') >= 0: - out = self.get_scene_geometry(scene, geom[1:]) - if geom.find(',') < 0: - out = 'POINT(%s)' % geom + if geom.find(':') >= 0: + out = self.get_scene_geometry(scene, geom) + elif geom.find(',') < 0: + out = "'POINT(%s)'::geometry" % geom elif geom.find('(') < 0: - out = 'LINESTRING(%s)' % geom + out = "'LINESTRING(%s)'::geometry" % geom else: - out = 'POLYGON(%s)' % geom + out = "'POLYGON(%s)'::geometry" % geom - # TODO parse precision - return out, 0 + return "ST_SetSRID(%s, 4326)" % out def get_scene_geometry(self, default_scene, name): geoms = [] - defscene = self.load_scene(default_scene) for obj in name.split('+'): oname = obj.strip() if oname.startswith(':'): + assert_is_not_none(default_scene, "You need to set a scene") + defscene = self.load_scene(default_scene) wkt = defscene[oname[1:]] else: scene, obj = oname.split(':', 2) - scene_geoms = world.load_scene(scene) + scene_geoms = self.load_scene(scene) wkt = scene_geoms[obj] geoms.append("'%s'::geometry" % wkt) diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py index 1bb2acaa..201bf373 100644 --- a/test/bdd/steps/db_ops.py +++ b/test/bdd/steps/db_ops.py @@ -8,7 +8,7 @@ import psycopg2.extras class PlaceColumn: def __init__(self, context, force_name): - self.columns = {} + self.columns = { 'admin_level' : 100} self.force_name = force_name self.context = context self.geometry = None @@ -41,14 +41,12 @@ class PlaceColumn: def set_key_housenr(self, value): self.columns['housenumber'] = value - def set_key_cc(self, value): - ok_(len(value) == 2) + def set_key_country(self, value): self.columns['country_code'] = value def set_key_geometry(self, value): - geom, precision = self.context.osm.parse_geometry(value, self.context.scene) - assert_is_not_none(geom) - self.geometry = "ST_SetSRID('%s'::geometry, 4326)" % geom + self.geometry = self.context.osm.parse_geometry(value, self.context.scene) + assert_is_not_none(self.geometry) def add_hstore(self, column, key, value): if column in self.columns: @@ -104,6 +102,9 @@ class NominatimID: return where, params +@given(u'the scene (?P.+)') +def set_default_scene(context, scene): + context.scene = scene @given("the (?Pnamed )?places") def add_data_to_place_table(context, named): @@ -169,5 +170,18 @@ def check_placex_contents(context, exact): assert_almost_equal(float(x) * fac, res['cx']) assert_almost_equal(float(y) * fac, res['cy']) else: - eq_(row[h], str(res[h])) + eq_(row[h], str(res[h]), + "Row '%s': expected: %s, got: %s" % (h, row[h], str(res[h]))) + context.db.commit() + +@then("placex has no entry for (?P.*)") +def check_placex_has_entry(context, oid): + cur = context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) + nid = NominatimID(oid) + where, params = nid.table_select() + cur.execute("""SELECT *, ST_AsText(geometry) as geomtxt, + ST_X(centroid) as cx, ST_Y(centroid) as cy + FROM placex where %s""" % where, + params) + eq_(0, cur.rowcount) context.db.commit() From 604706a8274b97fd3cb69ad384bfa42b1675d698 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Tue, 22 Nov 2016 23:52:45 +0100 Subject: [PATCH 05/29] ad search_name import tests --- test/bdd/db/import/search_name.feature | 40 +++++++++++ test/bdd/steps/db_ops.py | 91 +++++++++++++++++++++----- 2 files changed, 114 insertions(+), 17 deletions(-) create mode 100644 test/bdd/db/import/search_name.feature diff --git a/test/bdd/db/import/search_name.feature b/test/bdd/db/import/search_name.feature new file mode 100644 index 00000000..d66a538c --- /dev/null +++ b/test/bdd/db/import/search_name.feature @@ -0,0 +1,40 @@ +@DB +Feature: Creation of search terms + Tests that search_name table is filled correctly + + Scenario: POIs without a name have no search entry + Given the scene roads-with-pois + And the places + | osm | class | type | geometry | + | N1 | place | house | :p-N1 | + And the named places + | osm | class | type | geometry | + | W1 | highway | residential | :w-north | + When importing + Then search_name has no entry for N1 + + Scenario: Named POIs inherit address from parent + Given the scene roads-with-pois + And the places + | osm | class | type | name | geometry | + | N1 | place | house | foo | :p-N1 | + | W1 | highway | residential | the road | :w-north | + When importing + Then search_name contains + | object | name_vector | nameaddress_vector | + | N1 | foo | the road | + + @wip + Scenario: Roads take over the postcode from attached houses + Given the scene roads-with-pois + And the places + | osm | class | type | housenr | postcode | street | geometry | + | N1 | place | house | 1 | 12345 | North St | :p-S1 | + And the places + | osm | class | type | name | geometry | + | W1 | highway | residential | North St | :w-north | + When importing + Then search_name contains + | object | nameaddress_vector | + | W1 | 12345 | + diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py index 201bf373..7441e160 100644 --- a/test/bdd/steps/db_ops.py +++ b/test/bdd/steps/db_ops.py @@ -102,6 +102,31 @@ class NominatimID: return where, params + def get_place_id(self, cur): + where, params = self.table_select() + cur.execute("SELECT place_id FROM placex WHERE %s" % where, params) + eq_(1, cur.rowcount, "Expected exactly 1 entry in placex found %s" % cur.rowcount) + + return cur.fetchone()[0] + + +def assert_db_column(row, column, value): + if column == 'object': + return + + if column.startswith('centroid'): + fac = float(column[9:]) if h.startswith('centroid*') else 1.0 + x, y = value.split(' ') + assert_almost_equal(float(x) * fac, row['cx']) + assert_almost_equal(float(y) * fac, row['cy']) + else: + eq_(value, str(row[column]), + "Row '%s': expected: %s, got: %s" + % (column, value, str(row[column]))) + + +################################ STEPS ################################## + @given(u'the scene (?P.+)') def set_default_scene(context, scene): context.scene = scene @@ -139,13 +164,12 @@ def import_and_index_data_from_place_table(context): context.nominatim.run_setup_script('index', 'index-noanalyse') + @then("placex contains(?P exactly)?") def check_placex_contents(context, exact): cur = context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) - if exact: - cur.execute('SELECT osm_type, osm_id, class from placex') - to_match = [(r[0], r[1], r[2]) for r in cur] + expected_content = set() for row in context.table: nid = NominatimID(row['object']) where, params = nid.table_select() @@ -155,33 +179,66 @@ def check_placex_contents(context, exact): params) for res in cur: + if exact: + expected_content.add((res['osm_type'], res['osm_id'], res['class'])) for h in row.headings: - if h == 'object': - pass - elif h.startswith('name'): + if h.startswith('name'): name = h[5:] if h.startswith('name+') else 'name' assert_in(name, res['name']) eq_(res['name'][name], row[h]) elif h.startswith('extratags+'): eq_(res['extratags'][h[10:]], row[h]) - elif h.startswith('centroid'): - fac = float(h[9:]) if h.startswith('centroid*') else 1.0 - x, y = row[h].split(' ') - assert_almost_equal(float(x) * fac, res['cx']) - assert_almost_equal(float(y) * fac, res['cy']) else: - eq_(row[h], str(res[h]), - "Row '%s': expected: %s, got: %s" % (h, row[h], str(res[h]))) + assert_db_column(res, h, row[h]) + + if exact: + cur.execute('SELECT osm_type, osm_id, class from placex') + eq_(expected_content, set([(r[0], r[1], r[2]) for r in cur])) + context.db.commit() +@then("search_name contains") +def check_search_name_contents(context): + cur = context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) + + for row in context.table: + pid = NominatimID(row['object']).get_place_id(cur) + cur.execute("""SELECT *, ST_X(centroid) as cx, ST_Y(centroid) as cy + FROM search_name WHERE place_id = %s""", (pid, )) + + for res in cur: + for h in row.headings: + if h in ('name_vector', 'nameaddress_vector'): + terms = [x.strip().replace('#', ' ') for x in row[h].split(',')] + subcur = context.db.cursor() + subcur.execute("""SELECT word_id, word_token + FROM word, (SELECT unnest(%s) as term) t + WHERE word_token = make_standard_name(t.term)""", + (terms,)) + ok_(subcur.rowcount >= len(terms)) + for wid in subcur: + assert_in(wid[0], res[h], + "Missing term for %s/%s: %s" % (pid, h, wid[1])) + else: + assert_db_column(res, h, row[h]) + + + context.db.commit() + + @then("placex has no entry for (?P.*)") def check_placex_has_entry(context, oid): cur = context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) nid = NominatimID(oid) where, params = nid.table_select() - cur.execute("""SELECT *, ST_AsText(geometry) as geomtxt, - ST_X(centroid) as cx, ST_Y(centroid) as cy - FROM placex where %s""" % where, - params) + cur.execute("SELECT * FROM placex where %s" % where, params) + eq_(0, cur.rowcount) + context.db.commit() + +@then("search_name has no entry for (?P.*)") +def check_search_name_has_entry(context, oid): + cur = context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) + pid = NominatimID(oid).get_place_id(cur) + cur.execute("SELECT * FROM search_name WHERE place_id = %s", (pid, )) eq_(0, cur.rowcount) context.db.commit() From ddb72966631669a35b00f5830d19a133b8ff51de Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Fri, 25 Nov 2016 20:34:26 +0100 Subject: [PATCH 06/29] add parenting tests --- test/bdd/db/import/parenting.feature | 439 +++++++++++++++++++++++++ test/bdd/db/import/search_name.feature | 1 - test/bdd/steps/db_ops.py | 60 +++- 3 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 test/bdd/db/import/parenting.feature diff --git a/test/bdd/db/import/parenting.feature b/test/bdd/db/import/parenting.feature new file mode 100644 index 00000000..c00f4701 --- /dev/null +++ b/test/bdd/db/import/parenting.feature @@ -0,0 +1,439 @@ +@DB +Feature: Parenting of objects + Tests that the correct parent is choosen + + Scenario: Address inherits postcode from its street unless it has a postcode + Given the scene roads-with-pois + And the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 4 | :p-N1 | + And the places + | osm | class | type | housenr | postcode | geometry | + | N2 | place | house | 5 | 99999 | :p-N1 | + And the places + | osm | class | type | name | postcode | geometry | + | W1 | highway | residential | galoo | 12345 | :w-north | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W1 | + | N2 | W1 | + And search_name contains + | object | nameaddress_vector | + | N1 | 4, galoo, 12345 | + | N2 | 5, galoo, 99999 | + + Scenario: Address without tags, closest street + Given the scene roads-with-pois + And the places + | osm | class | type | geometry | + | N1 | place | house | :p-N1 | + | N2 | place | house | :p-N2 | + | N3 | place | house | :p-S1 | + | N4 | place | house | :p-S2 | + And the named places + | osm | class | type | geometry | + | W1 | highway | residential | :w-north | + | W2 | highway | residential | :w-south | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W1 | + | N2 | W1 | + | N3 | W2 | + | N4 | W2 | + + Scenario: Address without tags avoids unnamed streets + Given the scene roads-with-pois + And the places + | osm | class | type | geometry | + | N1 | place | house | :p-N1 | + | N2 | place | house | :p-N2 | + | N3 | place | house | :p-S1 | + | N4 | place | house | :p-S2 | + | W1 | highway | residential | :w-north | + And the named places + | osm | class | type | geometry | + | W2 | highway | residential | :w-south | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W2 | + | N2 | W2 | + | N3 | W2 | + | N4 | W2 | + + Scenario: addr:street tag parents to appropriately named street + Given the scene roads-with-pois + And the places + | osm | class | type | street| geometry | + | N1 | place | house | south | :p-N1 | + | N2 | place | house | north | :p-N2 | + | N3 | place | house | south | :p-S1 | + | N4 | place | house | north | :p-S2 | + And the places + | osm | class | type | name | geometry | + | W1 | highway | residential | north | :w-north | + | W2 | highway | residential | south | :w-south | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W2 | + | N2 | W1 | + | N3 | W2 | + | N4 | W1 | + + Scenario: addr:street tag parents to next named street + Given the scene roads-with-pois + And the places + | osm | class | type | street | geometry | + | N1 | place | house | abcdef | :p-N1 | + | N2 | place | house | abcdef | :p-N2 | + | N3 | place | house | abcdef | :p-S1 | + | N4 | place | house | abcdef | :p-S2 | + And the places + | osm | class | type | name | geometry | + | W1 | highway | residential | abcdef | :w-north | + | W2 | highway | residential | abcdef | :w-south | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W1 | + | N2 | W1 | + | N3 | W2 | + | N4 | W2 | + + Scenario: addr:street tag without appropriately named street + Given the scene roads-with-pois + And the places + | osm | class | type | street | geometry | + | N1 | place | house | abcdef | :p-N1 | + | N2 | place | house | abcdef | :p-N2 | + | N3 | place | house | abcdef | :p-S1 | + | N4 | place | house | abcdef | :p-S2 | + And the places + | osm | class | type | name | geometry | + | W1 | highway | residential | abcde | :w-north | + | W2 | highway | residential | abcde | :w-south | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W1 | + | N2 | W1 | + | N3 | W2 | + | N4 | W2 | + + Scenario: addr:place address + Given the scene road-with-alley + And the places + | osm | class | type | addr_place | geometry | + | N1 | place | house | myhamlet | :n-alley | + And the places + | osm | class | type | name | geometry | + | N2 | place | hamlet | myhamlet | :n-main-west | + | W1 | highway | residential | myhamlet | :w-main | + When importing + Then placex contains + | object | parent_place_id | + | N1 | N2 | + + Scenario: addr:street is preferred over addr:place + Given the scene road-with-alley + And the places + | osm | class | type | addr_place | street | geometry | + | N1 | place | house | myhamlet | mystreet| :n-alley | + And the places + | osm | class | type | name | geometry | + | N2 | place | hamlet | myhamlet | :n-main-west | + | W1 | highway | residential | mystreet | :w-main | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W1 | + + Scenario: Untagged address in simple associated street relation + Given the scene road-with-alley + And the places + | osm | class | type | geometry | + | N1 | place | house | :n-alley | + | N2 | place | house | :n-corner | + | N3 | place | house | :n-main-west | + And the places + | osm | class | type | name | geometry | + | W1 | highway | residential | foo | :w-main | + | W2 | highway | service | bar | :w-alley | + And the relations + | id | members | tags+type | + | 1 | W1:street,N1,N2,N3 | associatedStreet | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W1 | + | N2 | W1 | + | N3 | W1 | + + Scenario: Avoid unnamed streets in simple associated street relation + Given the scene road-with-alley + And the places + | osm | class | type | geometry | + | N1 | place | house | :n-alley | + | N2 | place | house | :n-corner | + | N3 | place | house | :n-main-west | + | W2 | highway | residential | :w-alley | + And the named places + | osm | class | type | geometry | + | W1 | highway | residential | :w-main | + And the relations + | id | members | tags+type | + | 1 | N1,N2,N3,W2:street,W1:street | associatedStreet | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W1 | + | N2 | W1 | + | N3 | W1 | + + Scenario: Associated street relation overrides addr:street + Given the scene road-with-alley + And the places + | osm | class | type | street | geometry | + | N1 | place | house | bar | :n-alley | + And the places + | osm | class | type | name | geometry | + | W1 | highway | residential | foo | :w-main | + | W2 | highway | residential | bar | :w-alley | + And the relations + | id | members | tags+type | + | 1 | W1:street,N1,N2,N3 | associatedStreet | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W1 | + + Scenario: Building without tags, closest street from center point + Given the scene building-on-street-corner + And the named places + | osm | class | type | geometry | + | W1 | building | yes | :w-building | + | W2 | highway | primary | :w-WE | + | W3 | highway | residential | :w-NS | + When importing + Then placex contains + | object | parent_place_id | + | W1 | W3 | + + Scenario: Building with addr:street tags + Given the scene building-on-street-corner + And the named places + | osm | class | type | street | geometry | + | W1 | building | yes | bar | :w-building | + And the places + | osm | class | type | name | geometry | + | W2 | highway | primary | bar | :w-WE | + | W3 | highway | residential | foo | :w-NS | + When importing + Then placex contains + | object | parent_place_id | + | W1 | W2 | + + Scenario: Building with addr:place tags + Given the scene building-on-street-corner + And the places + | osm | class | type | name | geometry | + | N1 | place | village | bar | :n-outer | + | W2 | highway | primary | bar | :w-WE | + | W3 | highway | residential | foo | :w-NS | + And the named places + | osm | class | type | addr_place | geometry | + | W1 | building | yes | bar | :w-building | + When importing + Then placex contains + | object | parent_place_id | + | W1 | N1 | + + Scenario: Building in associated street relation + Given the scene building-on-street-corner + And the named places + | osm | class | type | geometry | + | W1 | building | yes | :w-building | + And the places + | osm | class | type | name | geometry | + | W2 | highway | primary | bar | :w-WE | + | W3 | highway | residential | foo | :w-NS | + And the relations + | id | members | tags+type | + | 1 | W1:house,W2:street | associatedStreet | + When importing + Then placex contains + | object | parent_place_id | + | W1 | W2 | + + Scenario: Building in associated street relation overrides addr:street + Given the scene building-on-street-corner + And the named places + | osm | class | type | street | geometry | + | W1 | building | yes | foo | :w-building | + And the places + | osm | class | type | name | geometry | + | W2 | highway | primary | bar | :w-WE | + | W3 | highway | residential | foo | :w-NS | + And the relations + | id | members | tags+type | + | 1 | W1:house,W2:street | associatedStreet | + When importing + Then placex contains + | object | parent_place_id | + | W1 | W2 | + + Scenario: Wrong member in associated street relation is ignored + Given the scene building-on-street-corner + And the named places + | osm | class | type | geometry | + | N1 | place | house | :n-outer | + And the named places + | osm | class | type | street | geometry | + | W1 | building | yes | foo | :w-building | + And the places + | osm | class | type | name | geometry | + | W2 | highway | primary | bar | :w-WE | + | W3 | highway | residential | foo | :w-NS | + And the relations + | id | members | tags+type | + | 1 | N1:house,W1:street,W3:street | associatedStreet | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W3 | + + Scenario: POIs in building inherit address + Given the scene building-on-street-corner + And the named places + | osm | class | type | geometry | + | N1 | amenity | bank | :n-inner | + | N2 | shop | bakery | :n-edge-NS | + | N3 | shop | supermarket| :n-edge-WE | + And the places + | osm | class | type | street | addr_place | housenr | geometry | + | W1 | building | yes | foo | nowhere | 3 | :w-building | + And the places + | osm | class | type | name | geometry | + | W2 | highway | primary | bar | :w-WE | + | W3 | highway | residential | foo | :w-NS | + When importing + Then placex contains + | object | parent_place_id | street | addr_place | housenumber | + | W1 | W3 | foo | nowhere | 3 | + | N1 | W3 | foo | nowhere | 3 | + | N2 | W3 | foo | nowhere | 3 | + | N3 | W3 | foo | nowhere | 3 | + + Scenario: POIs don't inherit from streets + Given the scene building-on-street-corner + And the named places + | osm | class | type | geometry | + | N1 | amenity | bank | :n-inner | + And the places + | osm | class | type | street | addr_place | housenr | geometry | + | W1 | highway | path | foo | nowhere | 3 | :w-building | + And the places + | osm | class | type | name | geometry | + | W3 | highway | residential | foo | :w-NS | + When importing + Then placex contains + | object | parent_place_id | street | addr_place | housenumber | + | N1 | W3 | None | None | None | + + Scenario: POIs with own address do not inherit building address + Given the scene building-on-street-corner + And the named places + | osm | class | type | street | geometry | + | N1 | amenity | bank | bar | :n-inner | + And the named places + | osm | class | type | housenr | geometry | + | N2 | shop | bakery | 4 | :n-edge-NS | + And the named places + | osm | class | type | addr_place | geometry | + | N3 | shop | supermarket| nowhere | :n-edge-WE | + And the places + | osm | class | type | name | geometry | + | N4 | place | isolated_dwelling | theplace | :n-outer | + And the places + | osm | class | type | addr_place | housenr | geometry | + | W1 | building | yes | theplace | 3 | :w-building | + And the places + | osm | class | type | name | geometry | + | W2 | highway | primary | bar | :w-WE | + | W3 | highway | residential | foo | :w-NS | + When importing + Then placex contains + | object | parent_place_id | street | addr_place | housenumber | + | W1 | N4 | None | theplace | 3 | + | N1 | W2 | bar | None | None | + | N2 | W3 | None | None | 4 | + | N3 | W2 | None | nowhere | None | + + Scenario: POIs parent a road if they are attached to it + Given the scene points-on-roads + And the named places + | osm | class | type | street | geometry | + | N1 | highway | bus_stop | North St | :n-SE | + | N2 | highway | bus_stop | South St | :n-NW | + | N3 | highway | bus_stop | North St | :n-S-unglued | + | N4 | highway | bus_stop | South St | :n-N-unglued | + And the places + | osm | class | type | name | geometry | + | W1 | highway | secondary | North St | :w-north | + | W2 | highway | unclassified | South St | :w-south | + And the ways + | id | nodes | + | 1 | 100,101,2,103,104 | + | 2 | 200,201,1,202,203 | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W1 | + | N2 | W2 | + | N3 | W1 | + | N4 | W2 | + + Scenario: POIs do not parent non-roads they are attached to + Given the scene points-on-roads + And the named places + | osm | class | type | street | geometry | + | N1 | highway | bus_stop | North St | :n-SE | + | N2 | highway | bus_stop | South St | :n-NW | + And the places + | osm | class | type | name | geometry | + | W1 | landuse | residential | North St | :w-north | + | W2 | waterway| river | South St | :w-south | + And the ways + | id | nodes | + | 1 | 100,101,2,103,104 | + | 2 | 200,201,1,202,203 | + When importing + Then placex contains + | object | parent_place_id | + | N1 | 0 | + | N2 | 0 | + + Scenario: POIs on building outlines inherit associated street relation + Given the scene building-on-street-corner + And the named places + | osm | class | type | geometry | + | N1 | place | house | :n-edge-NS | + | W1 | building | yes | :w-building | + And the places + | osm | class | type | name | geometry | + | W2 | highway | primary | bar | :w-WE | + | W3 | highway | residential | foo | :w-NS | + And the relations + | id | members | tags+type | + | 1 | W1:house,W2:street | associatedStreet | + And the ways + | id | nodes | + | 1 | 100,1,101,102,100 | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W2 | + diff --git a/test/bdd/db/import/search_name.feature b/test/bdd/db/import/search_name.feature index d66a538c..98def330 100644 --- a/test/bdd/db/import/search_name.feature +++ b/test/bdd/db/import/search_name.feature @@ -24,7 +24,6 @@ Feature: Creation of search terms | object | name_vector | nameaddress_vector | | N1 | foo | the road | - @wip Scenario: Roads take over the postcode from attached houses Given the scene roads-with-pois And the places diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py index 7441e160..405404d8 100644 --- a/test/bdd/steps/db_ops.py +++ b/test/bdd/steps/db_ops.py @@ -63,7 +63,8 @@ class PlaceColumn: if self.columns['osm_type'] == 'N' and self.geometry is None: self.geometry = "ST_SetSRID(ST_Point(%f, %f), 4326)" % ( random.random()*360 - 180, random.random()*180 - 90) - + else: + assert_is_not_none(self.geometry, "Geometry missing") query = 'INSERT INTO place (%s, geometry) values(%s, %s)' % ( ','.join(self.columns.keys()), ','.join(['%s' for x in range(len(self.columns))]), @@ -76,7 +77,7 @@ class NominatimID: identifier instead that is of the form [:]. """ - id_regex = re.compile(r"(?P[NRW])(?P\d+)(?P:\w+)?") + id_regex = re.compile(r"(?P[NRW])(?P\d+)(:(?P\w+))?") def __init__(self, oid): self.typ = self.oid = self.cls = None @@ -146,6 +147,55 @@ def add_data_to_place_table(context, named): cur.close() context.db.commit() +@given("the relations") +def add_data_to_planet_relations(context): + cur = context.db.cursor() + for r in context.table: + last_node = 0 + last_way = 0 + parts = [] + members = [] + for m in r['members'].split(','): + mid = NominatimID(m) + if mid.typ == 'N': + parts.insert(last_node, int(mid.oid)) + members.insert(2 * last_node, mid.cls) + members.insert(2 * last_node, 'n' + mid.oid) + last_node += 1 + last_way += 1 + elif mid.typ == 'W': + parts.insert(last_way, int(mid.oid)) + members.insert(2 * last_way, mid.cls) + members.insert(2 * last_way, 'w' + mid.oid) + last_way += 1 + else: + parts.append(int(mid.oid)) + members.extend(('r' + mid.oid, mid.cls)) + + tags = [] + for h in r.headings: + if h.startswith("tags+"): + tags.extend((h[5:], r[h])) + + cur.execute("""INSERT INTO planet_osm_rels (id, way_off, rel_off, parts, members, tags) + VALUES (%s, %s, %s, %s, %s, %s)""", + (r['id'], last_node, last_way, parts, members, tags)) + context.db.commit() + +@given("the ways") +def add_data_to_planet_ways(context): + cur = context.db.cursor() + for r in context.table: + tags = [] + for h in r.headings: + if h.startswith("tags+"): + tags.extend((h[5:], r[h])) + + nodes = [ int(x.strip()) for x in r['nodes'].split(',') ] + + cur.execute("INSERT INTO planet_osm_ways (id, nodes, tags) VALUES (%s, %s, %s)", + (r['id'], nodes, tags)) + context.db.commit() @when("importing") def import_and_index_data_from_place_table(context): @@ -188,6 +238,12 @@ def check_placex_contents(context, exact): eq_(res['name'][name], row[h]) elif h.startswith('extratags+'): eq_(res['extratags'][h[10:]], row[h]) + elif h == 'parent_place_id': + if row[h] == '0': + eq_(0, res[h]) + else: + eq_(NominatimID(row[h]).get_place_id(context.db.cursor()), + res[h]) else: assert_db_column(res, h, row[h]) From e36e485698acc94d8402504ea8cc143d28b7d8f2 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Fri, 25 Nov 2016 20:53:56 +0100 Subject: [PATCH 07/29] add naming tests --- test/bdd/db/import/naming.feature | 39 +++++++ test/bdd/db/query/normalization.feature | 139 ++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 test/bdd/db/import/naming.feature create mode 100644 test/bdd/db/query/normalization.feature diff --git a/test/bdd/db/import/naming.feature b/test/bdd/db/import/naming.feature new file mode 100644 index 00000000..d2339376 --- /dev/null +++ b/test/bdd/db/import/naming.feature @@ -0,0 +1,39 @@ +@DB +Feature: Import and search of names + Tests all naming related import issues + + Scenario: No copying name tag if only one name + Given the places + | osm | class | type | name | geometry | + | N1 | place | locality | german | country:de | + When importing + Then placex contains + | object | calculated_country_code | name+name | + | N1 | de | german | + + Scenario: Copying name tag to default language if it does not exist + Given the places + | osm | class | type | name | name+name:fi | geometry | + | N1 | place | locality | german | finnish | country:de | + When importing + Then placex contains + | object | calculated_country_code | name | name+name:fi | name+name:de | + | N1 | de | german | finnish | german | + + Scenario: Copying default language name tag to name if it does not exist + Given the places + | osm | class | type | name+name:de | name+name:fi | geometry | + | N1 | place | locality | german | finnish | country:de | + When importing + Then placex contains + | object | calculated_country_code | name | name+name:fi | name+name:de | + | N1 | de | german | finnish | german | + + Scenario: Do not overwrite default language with name tag + Given the places + | osm | class | type | name | name+name:fi | name+name:de | geometry | + | N1 | place | locality | german | finnish | local | country:de | + When importing + Then placex contains + | object | calculated_country_code | name | name+name:fi | name+name:de | + | N1 | de | german | finnish | local | diff --git a/test/bdd/db/query/normalization.feature b/test/bdd/db/query/normalization.feature new file mode 100644 index 00000000..71c69dec --- /dev/null +++ b/test/bdd/db/query/normalization.feature @@ -0,0 +1,139 @@ +@DB +Feature: Import and search of names + Tests all naming related issues: normalisation, + abbreviations, internationalisation, etc. + + Scenario: Case-insensitivity of search + Given the places + | osm | class | type | name | + | N1 | place | locality | FooBar | + When importing + Then placex contains + | object | class | type | name+name | + | N1 | place | locality | FooBar | + When searching for "FooBar" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + When searching for "foobar" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + When searching for "fOObar" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + When searching for "FOOBAR" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + + Scenario: Multiple spaces in name + Given the places + | osm | class | type | name | + | N1 | place | locality | one two three | + When importing + When searching for "one two three" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + When searching for "one two three" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + When searching for "one two three" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + When searching for " one two three" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + + Scenario: Special characters in name + Given the places + | osm | class | type | name | + | N1 | place | locality | Jim-Knopf-Str | + | N2 | place | locality | Smith/Weston | + | N3 | place | locality | space mountain | + | N4 | place | locality | space | + | N5 | place | locality | mountain | + When importing + When searching for "Jim-Knopf-Str" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + When searching for "Jim Knopf-Str" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + When searching for "Jim Knopf Str" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + When searching for "Jim/Knopf-Str" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + When searching for "Jim-Knopfstr" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 1 | + When searching for "Smith/Weston" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 2 | + When searching for "Smith Weston" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 2 | + When searching for "Smith-Weston" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 2 | + When searching for "space mountain" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 3 | + When searching for "space-mountain" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 3 | + When searching for "space/mountain" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 3 | + When searching for "space\mountain" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 3 | + When searching for "space(mountain)" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 3 | + + Scenario: Landuse with name are found + Given the places + | osm | class | type | name | geometry | + | R1 | natural | meadow | landuse1 | (0 0, 1 0, 1 1, 0 1, 0 0) | + | R2 | landuse | industrial | landuse2 | (0 0, -1 0, -1 -1, 0 -1, 0 0) | + When importing + When searching for "landuse1" + Then results contain + | ID | osm_type | osm_id | + | 0 | R | 1 | + When searching for "landuse2" + Then results contain + | ID | osm_type | osm_id | + | 0 | R | 2 | + + @wip + Scenario: Postcode boundaries without ref + Given the places + | osm | class | type | postcode | geometry | + | R1 | boundary | postal_code | 12345 | (0 0, 1 0, 1 1, 0 1, 0 0) | + When importing + When searching for "12345" + Then results contain + | ID | osm_type | osm_id | + | 0 | R | 1 | From 82a0e23265bac61904dd3692f0fd57a73d2b7dc3 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Sat, 26 Nov 2016 23:04:31 +0100 Subject: [PATCH 08/29] tests for linking --- test/bdd/db/import/linking.feature | 110 +++++++++++++++++++++++++++++ test/bdd/steps/db_ops.py | 38 +++++----- 2 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 test/bdd/db/import/linking.feature diff --git a/test/bdd/db/import/linking.feature b/test/bdd/db/import/linking.feature new file mode 100644 index 00000000..0954ed4e --- /dev/null +++ b/test/bdd/db/import/linking.feature @@ -0,0 +1,110 @@ +@DB +Feature: Linking of places + Tests for correctly determining linked places + + Scenario: Only address-describing places can be linked + Given the scene way-area-with-center + And the places + | osm | class | type | name | geometry | + | R13 | landuse | forest | Garbo | :area | + | N256 | natural | peak | Garbo | :inner-C | + When importing + Then placex contains + | object | linked_place_id | + | R13 | - | + | N256 | - | + + Scenario: Waterways are linked when in waterway relations + Given the scene split-road + And the places + | osm | class | type | name | geometry | + | W1 | waterway | river | Rhein | :w-2 | + | W2 | waterway | river | Rhein | :w-3 | + | R13 | waterway | river | Rhein | :w-1 + :w-2 + :w-3 | + | R23 | waterway | river | Limmat| :w-4a | + And the relations + | id | members | tags+type | + | 13 | R23:tributary,W1,W2:main_stream | waterway | + When importing + Then placex contains + | object | linked_place_id | + | W1 | R13 | + | W2 | R13 | + | R13 | - | + | R23 | - | + When searching for "rhein" + Then results contain + | osm_type | + | R | + + Scenario: Relations are not linked when in waterway relations + Given the scene split-road + And the places + | osm | class | type | name | geometry | + | W1 | waterway | river | Rhein | :w-2 | + | W2 | waterway | river | Rhein | :w-3 | + | R1 | waterway | river | Rhein | :w-1 + :w-2 + :w-3 | + | R2 | waterway | river | Limmat| :w-4a | + And the relations + | id | members | tags+type | + | 1 | R2 | waterway | + When importing + Then placex contains + | object | linked_place_id | + | W1 | - | + | W2 | - | + | R1 | - | + | R2 | - | + + Scenario: Empty waterway relations are handled correctly + Given the scene split-road + And the places + | osm | class | type | name | geometry | + | R1 | waterway | river | Rhein | :w-1 + :w-2 + :w-3 | + And the relations + | id | members | tags+type | + | 1 | | waterway | + When importing + Then placex contains + | object | linked_place_id | + | R1 | - | + + Scenario: Waterways are not linked when waterway types don't match + Given the scene split-road + And the places + | osm | class | type | name | geometry | + | W1 | waterway | drain | Rhein | :w-2 | + | R1 | waterway | river | Rhein | :w-1 + :w-2 + :w-3 | + And the relations + | id | members | tags+type | + | 1 | N23,N34,W1,R45 | multipolygon | + When importing + Then placex contains + | object | linked_place_id | + | W1 | - | + | R1 | - | + When searching for "rhein" + Then results contain + | ID | osm_type | + | 0 | R | + | 1 | W | + + Scenario: Side streams are linked only when they have the same name + Given the scene split-road + And the places + | osm | class | type | name | geometry | + | W1 | waterway | river | Rhein2 | :w-2 | + | W2 | waterway | river | Rhein | :w-3 | + | R1 | waterway | river | Rhein | :w-1 + :w-2 + :w-3 | + And the relations + | id | members | tags+type | + | 1 | W1:side_stream,W2:side_stream | waterway | + When importing + Then placex contains + | object | linked_place_id | + | W1 | - | + | W2 | R1 | + When searching for "rhein2" + Then results contain + | osm_type | + | W | diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py index 405404d8..3f6c7976 100644 --- a/test/bdd/steps/db_ops.py +++ b/test/bdd/steps/db_ops.py @@ -154,23 +154,23 @@ def add_data_to_planet_relations(context): last_node = 0 last_way = 0 parts = [] - members = [] - for m in r['members'].split(','): - mid = NominatimID(m) - if mid.typ == 'N': - parts.insert(last_node, int(mid.oid)) - members.insert(2 * last_node, mid.cls) - members.insert(2 * last_node, 'n' + mid.oid) - last_node += 1 - last_way += 1 - elif mid.typ == 'W': - parts.insert(last_way, int(mid.oid)) - members.insert(2 * last_way, mid.cls) - members.insert(2 * last_way, 'w' + mid.oid) - last_way += 1 - else: - parts.append(int(mid.oid)) - members.extend(('r' + mid.oid, mid.cls)) + if r['members']: + members = [] + for m in r['members'].split(','): + mid = NominatimID(m) + if mid.typ == 'N': + parts.insert(last_node, int(mid.oid)) + last_node += 1 + last_way += 1 + elif mid.typ == 'W': + parts.insert(last_way, int(mid.oid)) + last_way += 1 + else: + parts.append(int(mid.oid)) + + members.extend((mid.typ.lower() + mid.oid, mid.cls or '')) + else: + members = None tags = [] for h in r.headings: @@ -238,9 +238,11 @@ def check_placex_contents(context, exact): eq_(res['name'][name], row[h]) elif h.startswith('extratags+'): eq_(res['extratags'][h[10:]], row[h]) - elif h == 'parent_place_id': + elif h in ('linked_place_id', 'parent_place_id'): if row[h] == '0': eq_(0, res[h]) + elif row[h] == '-': + assert_is_none(res[h]) else: eq_(NominatimID(row[h]).get_place_id(context.db.cursor()), res[h]) From 7f4e7a257940088e17046487f618e59f6aeb8ce9 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Sun, 27 Nov 2016 00:47:37 +0100 Subject: [PATCH 09/29] add interpolation tests --- test/bdd/db/import/interpolation.feature | 303 +++++++++++++++++++++++ test/bdd/steps/db_ops.py | 56 ++++- 2 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 test/bdd/db/import/interpolation.feature diff --git a/test/bdd/db/import/interpolation.feature b/test/bdd/db/import/interpolation.feature new file mode 100644 index 00000000..4ed66b91 --- /dev/null +++ b/test/bdd/db/import/interpolation.feature @@ -0,0 +1,303 @@ +@DB +Feature: Import of address interpolations + Tests that interpolated addresses are added correctly + + Scenario: Simple even interpolation line with two points + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 1 1 | + | N2 | place | house | 6 | 1 1.001 | + | W1 | place | houses | even | 1 1, 1 1.001 | + And the ways + | id | nodes | + | 1 | 1,2 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 2 | 6 | 1 1, 1 1.001 | + + Scenario: Backwards even two point interpolation line + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 1 1 | + | N2 | place | house | 6 | 1 1.001 | + | W1 | place | houses | even | 1 1.001, 1 1 | + And the ways + | id | nodes | + | 1 | 2,1 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 2 | 6 | 1 1, 1 1.001 | + + Scenario: Simple odd two point interpolation + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 1 | 1 1 | + | N2 | place | house | 11 | 1 1.001 | + | W1 | place | houses | odd | 1 1, 1 1.001 | + And the ways + | id | nodes | + | 1 | 1,2 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 1 | 11 | 1 1, 1 1.001 | + + Scenario: Simple all two point interpolation + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 1 | 1 1 | + | N2 | place | house | 3 | 1 1.001 | + | W1 | place | houses | all | 1 1, 1 1.001 | + And the ways + | id | nodes | + | 1 | 1,2 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 1 | 3 | 1 1, 1 1.001 | + + Scenario: Even two point interpolation line with intermediate empty node + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 1 1 | + | N2 | place | house | 10 | 1.001 1.001 | + | W1 | place | houses | even | 1 1, 1 1.001, 1.001 1.001 | + And the ways + | id | nodes | + | 1 | 1,3,2 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 2 | 10 | 1 1, 1 1.001, 1.001 1.001 | + + Scenario: Even two point interpolation line with intermediate duplicated empty node + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 1 1 | + | N2 | place | house | 10 | 1.001 1.001 | + | W1 | place | houses | even | 1 1, 1 1.001, 1.001 1.001 | + And the ways + | id | nodes | + | 1 | 1,3,3,2 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 2 | 10 | 1 1, 1 1.001, 1.001 1.001 | + + Scenario: Simple even three point interpolation line + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 1 1 | + | N2 | place | house | 14 | 1.001 1.001 | + | N3 | place | house | 10 | 1 1.001 | + | W1 | place | houses | even | 1 1, 1 1.001, 1.001 1.001 | + And the ways + | id | nodes | + | 1 | 1,3,2 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 2 | 10 | 1 1, 1 1.001 | + | 10 | 14 | 1 1.001, 1.001 1.001 | + + Scenario: Simple even four point interpolation line + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 1 1 | + | N2 | place | house | 14 | 1.001 1.001 | + | N3 | place | house | 10 | 1 1.001 | + | N4 | place | house | 18 | 1.001 1.002 | + | W1 | place | houses | even | 1 1, 1 1.001, 1.001 1.001, 1.001 1.002 | + And the ways + | id | nodes | + | 1 | 1,3,2,4 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 2 | 10 | 1 1, 1 1.001 | + | 10 | 14 | 1 1.001, 1.001 1.001 | + | 14 | 18 | 1.001 1.001, 1.001 1.002 | + + Scenario: Reverse simple even three point interpolation line + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 1 1 | + | N2 | place | house | 14 | 1.001 1.001 | + | N3 | place | house | 10 | 1 1.001 | + | W1 | place | houses | even | 1.001 1.001, 1 1.001, 1 1 | + And the ways + | id | nodes | + | 1 | 2,3,1 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 2 | 10 | 1 1, 1 1.001 | + | 10 | 14 | 1 1.001, 1.001 1.001 | + + Scenario: Even three point interpolation line with odd center point + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 1 1 | + | N2 | place | house | 8 | 1.001 1.001 | + | N3 | place | house | 7 | 1 1.001 | + | W1 | place | houses | even | 1 1, 1 1.001, 1.001 1.001 | + And the ways + | id | nodes | + | 1 | 1,3,2 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 2 | 7 | 1 1, 1 1.001 | + | 7 | 8 | 1 1.001, 1.001 1.001 | + + Scenario: Interpolation line with self-intersecting way + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 0 0 | + | N2 | place | house | 6 | 0 0.001 | + | N3 | place | house | 10 | 0 0.002 | + | W1 | place | houses | even | 0 0, 0 0.001, 0 0.002, 0 0.001 | + And the ways + | id | nodes | + | 1 | 1,2,3,2 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 2 | 6 | 0 0, 0 0.001 | + | 6 | 10 | 0 0.001, 0 0.002 | + | 6 | 10 | 0 0.001, 0 0.002 | + + Scenario: Interpolation line with self-intersecting way II + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 0 0 | + | N2 | place | house | 6 | 0 0.001 | + | W1 | place | houses | even | 0 0, 0 0.001, 0 0.002, 0 0.001 | + And the ways + | id | nodes | + | 1 | 1,2,3,2 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 2 | 6 | 0 0, 0 0.001 | + + Scenario: addr:street on interpolation way + Given the scene parallel-road + And the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | :n-middle-w | + | N2 | place | house | 6 | :n-middle-e | + | N3 | place | house | 12 | :n-middle-w | + | N4 | place | house | 16 | :n-middle-e | + And the places + | osm | class | type | housenr | street | geometry | + | W10 | place | houses | even | | :w-middle | + | W11 | place | houses | even | Cloud Street | :w-middle | + And the places + | osm | class | type | name | geometry | + | W2 | highway | tertiary | Sun Way | :w-north | + | W3 | highway | tertiary | Cloud Street | :w-south | + And the ways + | id | nodes | + | 10 | 1,100,101,102,2 | + | 11 | 3,200,201,202,4 | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W2 | + | N2 | W2 | + | N3 | W3 | + | N4 | W3 | + Then W10 expands to interpolation + | parent_place_id | start | end | + | W2 | 2 | 6 | + Then W11 expands to interpolation + | parent_place_id | start | end | + | W3 | 12 | 16 | + When searching for "16 Cloud Street" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 4 | + When searching for "14 Cloud Street" + Then results contain + | ID | osm_type | osm_id | + | 0 | W | 11 | + When searching for "18 Cloud Street" + Then results contain + | ID | osm_type | osm_id | + | 0 | W | 3 | + + Scenario: addr:street on housenumber way + Given the scene parallel-road + And the places + | osm | class | type | housenr | street | geometry | + | N1 | place | house | 2 | | :n-middle-w | + | N2 | place | house | 6 | | :n-middle-e | + | N3 | place | house | 12 | Cloud Street | :n-middle-w | + | N4 | place | house | 16 | Cloud Street | :n-middle-e | + And the places + | osm | class | type | housenr | geometry | + | W10 | place | houses | even | :w-middle | + | W11 | place | houses | even | :w-middle | + And the places + | osm | class | type | name | geometry | + | W2 | highway | tertiary | Sun Way | :w-north | + | W3 | highway | tertiary | Cloud Street | :w-south | + And the ways + | id | nodes | + | 10 | 1,100,101,102,2 | + | 11 | 3,200,201,202,4 | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W2 | + | N2 | W2 | + | N3 | W3 | + | N4 | W3 | + Then W10 expands to interpolation + | parent_place_id | start | end | + | W2 | 2 | 6 | + Then W11 expands to interpolation + | parent_place_id | start | end | + | W3 | 12 | 16 | + When searching for "16 Cloud Street" + Then results contain + | ID | osm_type | osm_id | + | 0 | N | 4 | + When searching for "14 Cloud Street" + Then results contain + | ID | osm_type | osm_id | + | 0 | W | 11 | + + Scenario: Geometry of points and way don't match (github #253) + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 10 | 144.9632341 -37.76163 | + | N2 | place | house | 6 | 144.9630541 -37.7628174 | + | N3 | shop | supermarket | 2 | 144.9629794 -37.7630755 | + | W1 | place | houses | even | 144.9632341 -37.76163,144.9630541 -37.7628172,144.9629794 -37.7630755 | + And the ways + | id | nodes | + | 1 | 1,2,3 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 2 | 6 | 144.9629794 -37.7630755, 144.9630541 -37.7628174 | + | 6 | 10 | 144.9630541 -37.7628174, 144.9632341 -37.76163 | + + Scenario: Place with missing address information + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 23 | 0.0001 0.0001 | + | N2 | amenity | school | | 0.0001 0.0002 | + | N3 | place | house | 29 | 0.0001 0.0004 | + | W1 | place | houses | odd | 0.0001 0.0001,0.0001 0.0002,0.0001 0.0004 | + And the ways + | id | nodes | + | 1 | 1,2,3 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 23 | 29 | 0.0001 0.0001, 0.0001 0.0002, 0.0001 0.0004 | diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py index 3f6c7976..da65984d 100644 --- a/test/bdd/steps/db_ops.py +++ b/test/bdd/steps/db_ops.py @@ -23,7 +23,7 @@ class PlaceColumn: else: assert_in(key, ('class', 'type', 'street', 'addr_place', 'isin', 'postcode')) - self.columns[key] = value + self.columns[key] = None if value == '' else value def set_key_name(self, value): self.add_hstore('name', 'name', value) @@ -39,10 +39,10 @@ class PlaceColumn: self.columns['admin_level'] = int(value) def set_key_housenr(self, value): - self.columns['housenumber'] = value + self.columns['housenumber'] = None if value == '' else value def set_key_country(self, value): - self.columns['country_code'] = value + self.columns['country_code'] = None if value == '' else value def set_key_geometry(self, value): self.geometry = self.context.osm.parse_geometry(value, self.context.scene) @@ -111,7 +111,7 @@ class NominatimID: return cur.fetchone()[0] -def assert_db_column(row, column, value): +def assert_db_column(row, column, value, context): if column == 'object': return @@ -120,6 +120,12 @@ def assert_db_column(row, column, value): x, y = value.split(' ') assert_almost_equal(float(x) * fac, row['cx']) assert_almost_equal(float(y) * fac, row['cy']) + elif column == 'geometry': + geom = context.osm.parse_geometry(value, context.scene) + cur = context.db.cursor() + cur.execute("SELECT ST_MaxDistance(%s, ST_SetSRID(%%s::geometry, 4326))" % geom, + (row['geomtxt'],)) + assert_less(cur.fetchone()[0], 0.005) else: eq_(value, str(row[column]), "Row '%s': expected: %s, got: %s" @@ -247,7 +253,7 @@ def check_placex_contents(context, exact): eq_(NominatimID(row[h]).get_place_id(context.db.cursor()), res[h]) else: - assert_db_column(res, h, row[h]) + assert_db_column(res, h, row[h], context) if exact: cur.execute('SELECT osm_type, osm_id, class from placex') @@ -278,11 +284,49 @@ def check_search_name_contents(context): assert_in(wid[0], res[h], "Missing term for %s/%s: %s" % (pid, h, wid[1])) else: - assert_db_column(res, h, row[h]) + assert_db_column(res, h, row[h], context) context.db.commit() +@then("(?P\w+) expands to interpolation") +def check_location_property_osmline(context, oid): + cur = context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) + nid = NominatimID(oid) + + eq_('W', nid.typ, "interpolation must be a way") + + cur.execute("""SELECT *, ST_AsText(linegeo) as geomtxt + FROM location_property_osmline WHERE osm_id = %s""", + (nid.oid, )) + + todo = list(range(len([context.table]))) + for res in cur: + for i in todo: + row = context.table[i] + if (int(row['start']) == res['startnumber'] + and int(row['end']) == res['endnumber']): + todo.remove(i) + break + else: + assert(False, "Unexpected row %s" % (str(res))) + + for h in row.headings: + if h in ('start', 'end'): + continue + elif h == 'parent_place_id': + if row[h] == '0': + eq_(0, res[h]) + elif row[h] == '-': + assert_is_none(res[h]) + else: + eq_(NominatimID(row[h]).get_place_id(context.db.cursor()), + res[h]) + else: + assert_db_column(res, h, row[h], context) + + eq_(todo, []) + @then("placex has no entry for (?P.*)") def check_placex_has_entry(context, oid): From c20f8b13a57f25ce9dff25cf17666f5ddb790812 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Sun, 27 Nov 2016 10:12:07 +0100 Subject: [PATCH 10/29] add simple db update tests --- test/bdd/db/update/simple.feature | 71 +++++++++++++++++++++++++++++++ test/bdd/environment.py | 3 ++ test/bdd/steps/db_ops.py | 37 +++++++++++++--- 3 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 test/bdd/db/update/simple.feature diff --git a/test/bdd/db/update/simple.feature b/test/bdd/db/update/simple.feature new file mode 100644 index 00000000..0833c90c --- /dev/null +++ b/test/bdd/db/update/simple.feature @@ -0,0 +1,71 @@ +@DB +Feature: Update of simple objects + Testing simple updating functionality + + Scenario: Do delete small boundary features + Given the places + | osm | class | type | admin | geometry | + | R1 | boundary | administrative | 3 | poly-area:1.0 | + When importing + Then placex contains + | object | rank_search | + | R1 | 6 | + When marking for delete R1 + Then placex has no entry for R1 + + Scenario: Do not delete large boundary features + Given the places + | osm | class | type | admin | geometry | + | R1 | boundary | administrative | 3 | poly-area:5.0 | + When importing + Then placex contains + | object | rank_search | + | R1 | 6 | + When marking for delete R1 + Then placex contains + | object | rank_search | + | R1 | 6 | + + Scenario: Do delete large features of low rank + Given the named places + | osm | class | type | geometry | + | W1 | place | house | poly-area:5.0 | + | R1 | boundary | national_park | poly-area:5.0 | + When importing + Then placex contains + | object | rank_address | + | R1 | 0 | + | W1 | 30 | + When marking for delete R1,W1 + Then placex has no entry for W1 + Then placex has no entry for R1 + + Scenario: type mutation + Given the places + | osm | class | type | geometry | + | N3 | shop | toys | 1 -1 | + When importing + Then placex contains + | object | class | type | centroid | + | N3 | shop | toys | 1 -1 | + When updating places + | osm | class | type | geometry | + | N3 | shop | grocery | 1 -1 | + Then placex contains + | object | class | type | centroid | + | N3 | shop | grocery | 1 -1 | + + Scenario: remove postcode place when house number is added + Given the places + | osm | class | type | postcode | geometry | + | N3 | place | postcode | 12345 | 1 -1 | + When importing + Then placex contains + | object | class | type | + | N3 | place | postcode | + When updating places + | osm | class | type | postcode | housenr | geometry | + | N3 | place | house | 12345 | 13 | 1 -1 | + Then placex contains + | object | class | type | + | N3 | place | house | diff --git a/test/bdd/environment.py b/test/bdd/environment.py index a954b253..cf844f1e 100644 --- a/test/bdd/environment.py +++ b/test/bdd/environment.py @@ -125,6 +125,9 @@ class NominatimEnvironment(object): def run_setup_script(self, *args): self.run_nominatim_script('setup', *args) + def run_update_script(self, *args): + self.run_nominatim_script('update', *args) + def run_nominatim_script(self, script, *args): cmd = [os.path.join(self.build_dir, 'utils', '%s.php' % script)] cmd.extend(['--%s' % x for x in args]) diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py index da65984d..82209462 100644 --- a/test/bdd/steps/db_ops.py +++ b/test/bdd/steps/db_ops.py @@ -98,7 +98,7 @@ class NominatimID: params = [self.typ, self. oid] if self.cls is not None: - where += ' class = %s' + where += ' and class = %s' params.append(self.cls) return where, params @@ -116,16 +116,16 @@ def assert_db_column(row, column, value, context): return if column.startswith('centroid'): - fac = float(column[9:]) if h.startswith('centroid*') else 1.0 + fac = float(column[9:]) if column.startswith('centroid*') else 1.0 x, y = value.split(' ') assert_almost_equal(float(x) * fac, row['cx']) assert_almost_equal(float(y) * fac, row['cy']) elif column == 'geometry': geom = context.osm.parse_geometry(value, context.scene) cur = context.db.cursor() - cur.execute("SELECT ST_MaxDistance(%s, ST_SetSRID(%%s::geometry, 4326))" % geom, + cur.execute("SELECT ST_Equals(%s, ST_SetSRID(%%s::geometry, 4326))" % geom, (row['geomtxt'],)) - assert_less(cur.fetchone()[0], 0.005) + eq_(cur.fetchone()[0], True) else: eq_(value, str(row[column]), "Row '%s': expected: %s, got: %s" @@ -219,7 +219,32 @@ def import_and_index_data_from_place_table(context): context.db.commit() context.nominatim.run_setup_script('index', 'index-noanalyse') +@when("updating places") +def update_place_table(context): + context.nominatim.run_setup_script( + 'create-functions', 'create-partition-functions', 'enable-diff-updates') + cur = context.db.cursor() + for r in context.table: + col = PlaceColumn(context, False) + for h in r.headings: + col.add(h, r[h]) + + col.db_insert(cur) + + context.db.commit() + context.nominatim.run_update_script('index') + +@when("marking for delete (?P.*)") +def delete_places(context, oids): + context.nominatim.run_setup_script( + 'create-functions', 'create-partition-functions', 'enable-diff-updates') + cur = context.db.cursor() + for oid in oids.split(','): + where, params = NominatimID(oid).table_select() + cur.execute("DELETE FROM place WHERE " + where, params) + context.db.commit() + context.nominatim.run_update_script('index') @then("placex contains(?P exactly)?") def check_placex_contents(context, exact): @@ -300,7 +325,7 @@ def check_location_property_osmline(context, oid): FROM location_property_osmline WHERE osm_id = %s""", (nid.oid, )) - todo = list(range(len([context.table]))) + todo = list(range(len(list(context.table)))) for res in cur: for i in todo: row = context.table[i] @@ -309,7 +334,7 @@ def check_location_property_osmline(context, oid): todo.remove(i) break else: - assert(False, "Unexpected row %s" % (str(res))) + assert False, "Unexpected row %s" % (str(res)) for h in row.headings: if h in ('start', 'end'): From 4f2d73aa7c185af5546094449355580d3564cdc6 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Sun, 27 Nov 2016 11:56:43 +0100 Subject: [PATCH 11/29] add tests for interpolation updates --- test/bdd/db/update/interpolation.feature | 244 +++++++++++++++++++++++ test/bdd/steps/db_ops.py | 8 +- 2 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 test/bdd/db/update/interpolation.feature diff --git a/test/bdd/db/update/interpolation.feature b/test/bdd/db/update/interpolation.feature new file mode 100644 index 00000000..a9e56cce --- /dev/null +++ b/test/bdd/db/update/interpolation.feature @@ -0,0 +1,244 @@ +@DB +Feature: Update of address interpolations + Test the interpolated address are updated correctly + + Scenario: addr:street added to interpolation + Given the scene parallel-road + And the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | :n-middle-w | + | N2 | place | house | 6 | :n-middle-e | + | W10 | place | houses | even | :w-middle | + And the places + | osm | class | type | name | geometry | + | W2 | highway | unclassified | Sun Way | :w-north | + | W3 | highway | unclassified | Cloud Street | :w-south | + And the ways + | id | nodes | + | 10 | 1,100,101,102,2 | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W2 | + | N2 | W2 | + And W10 expands to interpolation + | parent_place_id | start | end | + | W2 | 2 | 6 | + When updating places + | osm | class | type | housenr | street | geometry | + | W10 | place | houses | even | Cloud Street | :w-middle | + Then placex contains + | object | parent_place_id | + | N1 | W3 | + | N2 | W3 | + And W10 expands to interpolation + | parent_place_id | start | end | + | W3 | 2 | 6 | + + Scenario: addr:street added to housenumbers + Given the scene parallel-road + And the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | :n-middle-w | + | N2 | place | house | 6 | :n-middle-e | + | W10 | place | houses | even | :w-middle | + And the places + | osm | class | type | name | geometry | + | W2 | highway | unclassified | Sun Way | :w-north | + | W3 | highway | unclassified | Cloud Street | :w-south | + And the ways + | id | nodes | + | 10 | 1,100,101,102,2 | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W2 | + | N2 | W2 | + And W10 expands to interpolation + | parent_place_id | start | end | + | W2 | 2 | 6 | + When updating places + | osm | class | type | street | housenr | geometry | + | N1 | place | house | Cloud Street| 2 | :n-middle-w | + | N2 | place | house | Cloud Street| 6 | :n-middle-e | + Then placex contains + | object | parent_place_id | + | N1 | W3 | + | N2 | W3 | + And W10 expands to interpolation + | parent_place_id | start | end | + | W3 | 2 | 6 | + + Scenario: interpolation tag removed + Given the scene parallel-road + And the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | :n-middle-w | + | N2 | place | house | 6 | :n-middle-e | + | W10 | place | houses | even | :w-middle | + And the places + | osm | class | type | name | geometry | + | W2 | highway | unclassified | Sun Way | :w-north | + | W3 | highway | unclassified | Cloud Street | :w-south | + And the ways + | id | nodes | + | 10 | 1,100,101,102,2 | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W2 | + | N2 | W2 | + And W10 expands to interpolation + | parent_place_id | start | end | + | W2 | 2 | 6 | + When marking for delete W10 + Then W10 expands to no interpolation + And placex contains + | object | parent_place_id | + | N1 | W2 | + | N2 | W2 | + + Scenario: referenced road added + Given the scene parallel-road + And the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | :n-middle-w | + | N2 | place | house | 6 | :n-middle-e | + And the places + | osm | class | type | housenr | street | geometry | + | W10 | place | houses | even | Cloud Street| :w-middle | + And the places + | osm | class | type | name | geometry | + | W2 | highway | unclassified | Sun Way | :w-north | + And the ways + | id | nodes | + | 10 | 1,100,101,102,2 | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W2 | + | N2 | W2 | + And W10 expands to interpolation + | parent_place_id | start | end | + | W2 | 2 | 6 | + When updating places + | osm | class | type | name | geometry | + | W3 | highway | unclassified | Cloud Street | :w-south | + Then placex contains + | object | parent_place_id | + | N1 | W3 | + | N2 | W3 | + And W10 expands to interpolation + | parent_place_id | start | end | + | W3 | 2 | 6 | + + Scenario: referenced road deleted + Given the scene parallel-road + And the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | :n-middle-w | + | N2 | place | house | 6 | :n-middle-e | + And the places + | osm | class | type | housenr | street | geometry | + | W10 | place | houses | even | Cloud Street| :w-middle | + And the places + | osm | class | type | name | geometry | + | W2 | highway | unclassified | Sun Way | :w-north | + | W3 | highway | unclassified | Cloud Street | :w-south | + And the ways + | id | nodes | + | 10 | 1,100,101,102,2 | + When importing + Then placex contains + | object | parent_place_id | + | N1 | W3 | + | N2 | W3 | + And W10 expands to interpolation + | parent_place_id | start | end | + | W3 | 2 | 6 | + When marking for delete W3 + Then placex contains + | object | parent_place_id | + | N1 | W2 | + | N2 | W2 | + And W10 expands to interpolation + | parent_place_id | start | end | + | W2 | 2 | 6 | + + Scenario: building becomes interpolation + Given the scene building-with-parallel-streets + And the places + | osm | class | type | housenr | geometry | + | W1 | place | house | 3 | :w-building | + And the places + | osm | class | type | name | geometry | + | W2 | highway | unclassified | Cloud Street | :w-south | + When importing + Then placex contains + | object | parent_place_id | + | W1 | W2 | + Given the ways + | id | nodes | + | 1 | 1,100,101,102,2 | + When updating places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | :n-north-w | + | N2 | place | house | 6 | :n-north-e | + And updating places + | osm | class | type | housenr | street | geometry | + | W1 | place | houses | even | Cloud Street| :w-north | + Then placex has no entry for W1 + And W1 expands to interpolation + | parent_place_id | start | end | + | W2 | 2 | 6 | + + Scenario: interpolation becomes building + Given the scene building-with-parallel-streets + And the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | :n-north-w | + | N2 | place | house | 6 | :n-north-e | + And the places + | osm | class | type | name | geometry | + | W2 | highway | unclassified | Cloud Street | :w-south | + And the ways + | id | nodes | + | 1 | 1,100,101,102,2 | + And the places + | osm | class | type | housenr | street | geometry | + | W1 | place | houses | even | Cloud Street| :w-north | + When importing + Then placex has no entry for W1 + And W1 expands to interpolation + | parent_place_id | start | end | + | W2 | 2 | 6 | + When updating places + | osm | class | type | housenr | geometry | + | W1 | place | house | 3 | :w-building | + Then placex contains + | object | parent_place_id | + | W1 | W2 | + + Scenario: housenumbers added to interpolation + Given the scene building-with-parallel-streets + And the places + | osm | class | type | name | geometry | + | W2 | highway | unclassified | Cloud Street | :w-south | + And the ways + | id | nodes | + | 1 | 1,100,101,102,2 | + And the places + | osm | class | type | housenr | geometry | + | W1 | place | houses | even | :w-north | + When importing + Then W1 expands to no interpolation + When updating places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | :n-north-w | + | N2 | place | house | 6 | :n-north-e | + And updating places + | osm | class | type | housenr | street | geometry | + | W1 | place | houses | even | Cloud Street| :w-north | + Then W1 expands to interpolation + | parent_place_id | start | end | + | W2 | 2 | 6 | diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py index 82209462..b7fa1a88 100644 --- a/test/bdd/steps/db_ops.py +++ b/test/bdd/steps/db_ops.py @@ -314,8 +314,8 @@ def check_search_name_contents(context): context.db.commit() -@then("(?P\w+) expands to interpolation") -def check_location_property_osmline(context, oid): +@then("(?P\w+) expands to(?P no)? interpolation") +def check_location_property_osmline(context, oid, neg): cur = context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) nid = NominatimID(oid) @@ -325,6 +325,10 @@ def check_location_property_osmline(context, oid): FROM location_property_osmline WHERE osm_id = %s""", (nid.oid, )) + if neg: + eq_(0, cur.rowcount) + return + todo = list(range(len(list(context.table)))) for res in cur: for i in todo: From 0e9e2bbdca37801215d5b3c1e65c9c417bf27796 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Sun, 27 Nov 2016 13:39:43 +0100 Subject: [PATCH 12/29] add tests for updating linked features --- test/bdd/db/update/linked_places.feature | 91 ++++++++++++++++++++++++ test/bdd/steps/queries.py | 7 +- 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 test/bdd/db/update/linked_places.feature diff --git a/test/bdd/db/update/linked_places.feature b/test/bdd/db/update/linked_places.feature new file mode 100644 index 00000000..17ca8003 --- /dev/null +++ b/test/bdd/db/update/linked_places.feature @@ -0,0 +1,91 @@ +@DB +Feature: Updates of linked places + Tests that linked places are correctly added and deleted. + + Scenario: Add linked place when linking relation is renamed + Given the places + | osm | class | type | name | geometry | + | N1 | place | city | foo | 0 0 | + And the places + | osm | class | type | name | admin | geometry | + | R1 | boundary | administrative | foo | 8 | poly-area:0.1 | + When importing + And searching for "foo" with dups + Then results contain + | osm_type | + | R | + When updating places + | osm | class | type | name | admin | geometry | + | R1 | boundary | administrative | foobar | 8 | poly-area:0.1 | + Then placex contains + | object | linked_place_id | + | N1 | - | + When searching for "foo" with dups + Then results contain + | osm_type | + | N | + + Scenario: Add linked place when linking relation is removed + Given the places + | osm | class | type | name | geometry | + | N1 | place | city | foo | 0 0 | + And the places + | osm | class | type | name | admin | geometry | + | R1 | boundary | administrative | foo | 8 | poly-area:0.1 | + When importing + And searching for "foo" with dups + Then results contain + | osm_type | + | R | + When marking for delete R1 + Then placex contains + | object | linked_place_id | + | N1 | - | + When searching for "foo" with dups + Then results contain + | osm_type | + | N | + + Scenario: Remove linked place when linking relation is added + Given the places + | osm | class | type | name | geometry | + | N1 | place | city | foo | 0 0 | + When importing + And searching for "foo" with dups + Then results contain + | osm_type | + | N | + When updating places + | osm | class | type | name | admin | geometry | + | R1 | boundary | administrative | foo | 8 | poly-area:0.1 | + Then placex contains + | object | linked_place_id | + | N1 | R1 | + When searching for "foo" with dups + Then results contain + | osm_type | + | R | + + Scenario: Remove linked place when linking relation is renamed + Given the places + | osm | class | type | name | geometry | + | N1 | place | city | foo | 0 0 | + And the places + | osm | class | type | name | admin | geometry | + | R1 | boundary | administrative | foobar | 8 | poly-area:0.1 | + When importing + And searching for "foo" with dups + Then results contain + | osm_type | + | N | + When updating places + | osm | class | type | name | admin | geometry | + | R1 | boundary | administrative | foo | 8 | poly-area:0.1 | + Then placex contains + | object | linked_place_id | + | N1 | R1 | + When searching for "foo" with dups + Then results contain + | osm_type | + | R | + diff --git a/test/bdd/steps/queries.py b/test/bdd/steps/queries.py index acb7ee91..f37f7e7b 100644 --- a/test/bdd/steps/queries.py +++ b/test/bdd/steps/queries.py @@ -44,8 +44,8 @@ class SearchResponse(object): assert_equal(str(res[h]), str(row[h])) -@when(u'searching for "(?P.*)"') -def query_cmd(context, query): +@when(u'searching for "(?P.*)"(?P with dups)?') +def query_cmd(context, query, dups): """ Query directly via PHP script. """ cmd = [os.path.join(context.nominatim.build_dir, 'utils', 'query.php'), @@ -57,6 +57,9 @@ def query_cmd(context, query): if value: cmd.extend(('--' + h, value)) + if dups: + cmd.extend(('--dedupe', '0')) + proc = subprocess.Popen(cmd, cwd=context.nominatim.build_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (outp, err) = proc.communicate() From 21a3fc4b0fa98e591a8114b684db69059eb9d26a Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Sun, 27 Nov 2016 14:44:21 +0100 Subject: [PATCH 13/29] add remaining db update tests --- test/bdd/db/query/search_simple.feature | 19 +++++++ test/bdd/db/update/naming.feature | 18 ++++++ .../db/update/poi-inherited-postcode.feature | 57 +++++++++++++++++++ test/bdd/db/update/search_terms.feature | 21 +++++++ 4 files changed, 115 insertions(+) create mode 100644 test/bdd/db/update/naming.feature create mode 100644 test/bdd/db/update/poi-inherited-postcode.feature create mode 100644 test/bdd/db/update/search_terms.feature diff --git a/test/bdd/db/query/search_simple.feature b/test/bdd/db/query/search_simple.feature index f0c66f13..417df769 100644 --- a/test/bdd/db/query/search_simple.feature +++ b/test/bdd/db/query/search_simple.feature @@ -11,3 +11,22 @@ Feature: Searching of simple objects Then results contain | ID | osm | class | type | centroid | | 0 | N1 | place | village | 10 -10 | + + Scenario: Updating postcode in postcode boundaries without ref + Given the places + | osm | class | type | postcode | geometry | + | R1 | boundary | postal_code | 12345 | poly-area:1.0 | + When importing + And searching for "12345" + Then results contain + | ID | osm_type | osm_id | + | 0 | R | 1 | + When updating places + | osm | class | type | postcode | geometry | + | R1 | boundary | postal_code | 54321 | poly-area:1.0 | + And searching for "12345" + Then exactly 0 results are returned + When searching for "54321" + Then results contain + | ID | osm_type | osm_id | + | 0 | R | 1 | diff --git a/test/bdd/db/update/naming.feature b/test/bdd/db/update/naming.feature new file mode 100644 index 00000000..4b5222fc --- /dev/null +++ b/test/bdd/db/update/naming.feature @@ -0,0 +1,18 @@ +@DB +Feature: Update of names in place objects + Test all naming related issues in updates + + Scenario: Delete postcode from postcode boundaries without ref + Given the places + | osm | class | type | postcode | geometry | + | R1 | boundary | postal_code | 12345 | poly-area:0.5 | + When importing + And searching for "12345" + Then results contain + | ID | osm_type | osm_id | + | 0 | R | 1 | + When updating places + | osm | class | type | geometry | + | R1 | boundary | postal_code | poly-area:0.5 | + Then placex has no entry for R1 + diff --git a/test/bdd/db/update/poi-inherited-postcode.feature b/test/bdd/db/update/poi-inherited-postcode.feature new file mode 100644 index 00000000..1b2065e6 --- /dev/null +++ b/test/bdd/db/update/poi-inherited-postcode.feature @@ -0,0 +1,57 @@ +@DB +Feature: Update of POI-inherited poscode + Test updates of postcodes on street which was inherited from a related POI + + Background: Street and house with postcode + Given the scene roads-with-pois + And the places + | osm | class | type | housenr | postcode | street | geometry | + | N1 | place | house | 1 | 12345 | North St |:p-S1 | + And the places + | osm | class | type | name | geometry | + | W1 | highway | residential | North St | :w-north | + When importing + Then search_name contains + | object | nameaddress_vector | + | W1 | 12345 | + + Scenario: POI-inherited postcode remains when way type is changed + When updating places + | osm | class | type | name | geometry | + | W1 | highway | unclassified | North St | :w-north | + Then search_name contains + | object | nameaddress_vector | + | W1 | 12345 | + + Scenario: POI-inherited postcode remains when way name is changed + When updating places + | osm | class | type | name | geometry | + | W1 | highway | unclassified | South St | :w-north | + Then search_name contains + | object | nameaddress_vector | + | W1 | 12345 | + + Scenario: POI-inherited postcode remains when way geometry is changed + When updating places + | osm | class | type | name | geometry | + | W1 | highway | unclassified | South St | :w-south | + Then search_name contains + | object | nameaddress_vector | + | W1 | 12345 | + + Scenario: POI-inherited postcode is added when POI postcode changes + When updating places + | osm | class | type | housenr | postcode | street | geometry | + | N1 | place | house | 1 | 54321 | North St |:p-S1 | + Then search_name contains + | object | nameaddress_vector | + | W1 | 54321 | + + Scenario: POI-inherited postcode remains when POI geometry changes + When updating places + | osm | class | type | housenr | postcode | street | geometry | + | N1 | place | house | 1 | 12345 | North St |:p-S2 | + Then search_name contains + | object | nameaddress_vector | + | W1 | 12345 | + diff --git a/test/bdd/db/update/search_terms.feature b/test/bdd/db/update/search_terms.feature new file mode 100644 index 00000000..07dbd451 --- /dev/null +++ b/test/bdd/db/update/search_terms.feature @@ -0,0 +1,21 @@ +@DB +Feature: Update of search terms + Tests that search_name table is updated correctly + + Scenario: POI-inherited postcode remains when another POI is deleted + Given the scene roads-with-pois + And the places + | osm | class | type | housenr | postcode | street | geometry | + | N1 | place | house | 1 | 12345 | North St |:p-S1 | + | N2 | place | house | 2 | | North St |:p-S2 | + And the places + | osm | class | type | name | geometry | + | W1 | highway | residential | North St | :w-north | + When importing + Then search_name contains + | object | nameaddress_vector | + | W1 | 12345 | + When marking for delete N2 + Then search_name contains + | object | nameaddress_vector | + | W1 | 12345 | From f2debbef199337c98bbd21a29203da7378067ec9 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Sun, 27 Nov 2016 16:51:05 +0100 Subject: [PATCH 14/29] add simple osm2pgsql tests --- test/bdd/environment.py | 8 ++-- test/bdd/osm2pgsql/import/simple.feature | 59 ++++++++++++++++++++++++ test/bdd/steps/db_ops.py | 55 ++++++++++++++++++++-- test/bdd/steps/osm_data.py | 33 +++++++++++++ 4 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 test/bdd/osm2pgsql/import/simple.feature create mode 100644 test/bdd/steps/osm_data.py diff --git a/test/bdd/environment.py b/test/bdd/environment.py index cf844f1e..29c6675c 100644 --- a/test/bdd/environment.py +++ b/test/bdd/environment.py @@ -122,15 +122,17 @@ class NominatimEnvironment(object): if not self.keep_scenario_db: self.db_drop_database(self.test_db) - def run_setup_script(self, *args): - self.run_nominatim_script('setup', *args) + def run_setup_script(self, *args, **kwargs): + self.run_nominatim_script('setup', *args, **kwargs) def run_update_script(self, *args): self.run_nominatim_script('update', *args) - def run_nominatim_script(self, script, *args): + def run_nominatim_script(self, script, *args, **kwargs): cmd = [os.path.join(self.build_dir, 'utils', '%s.php' % script)] cmd.extend(['--%s' % x for x in args]) + for k, v in kwargs.items(): + cmd.extend(('--' + k.replace('_', '-'), str(v))) proc = subprocess.Popen(cmd, cwd=self.build_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (outp, outerr) = proc.communicate() diff --git a/test/bdd/osm2pgsql/import/simple.feature b/test/bdd/osm2pgsql/import/simple.feature new file mode 100644 index 00000000..46a18199 --- /dev/null +++ b/test/bdd/osm2pgsql/import/simple.feature @@ -0,0 +1,59 @@ +@DB +Feature: Import of simple objects by osm2pgsql + Testing basic tagging in osm2pgsql imports. + + Scenario: Import simple objects + When loading osm data + """ + n1 Tamenity=prison,name=foo x34.3 y-23 + n100 x0 y0 + n101 x0 y0.1 + n102 x0.1 y0.2 + n200 x0 y0 + n201 x0 y1 + n202 x1 y1 + n203 x1 y0 + w1 Tshop=toys,name=tata Nn100,n101,n102 + w2 Tref=45 Nn200,n201,n202,n203,n200 + r1 Ttype=multipolygon,tourism=hotel,name=XZ Mn1@,w2@ + """ + Then place contains exactly + | object | class | type | name | geometry | + | N1 | amenity | prison | 'name' : 'foo' | 34.3 -23 | + | W1 | shop | toys | 'name' : 'tata' | 0 0, 0 0.1, 0.1 0.2 | + | R1 | tourism | hotel | 'name' : 'XZ' | (0 0, 0 1, 1 1, 1 0, 0 0) | + + Scenario: Import object with two main tags + When loading osm data + """ + n1 Ttourism=hotel,amenity=restaurant,name=foo + """ + Then place contains + | object | type | name | + | N1:tourism | hotel | 'name' : 'foo' | + | N1:amenity | restaurant | 'name' : 'foo' | + + Scenario: Import stand-alone house number with postcode + When loading osm data + """ + n1 Taddr:housenumber=4,addr:postcode=3345 + """ + Then place contains + | object | class | type | + | N1 | place | house | + + Scenario: Landuses are only imported when named + When loading osm data + """ + n100 x0 y0 + n101 x0 y0.1 + n102 x0.1 y0.1 + n200 x0 y0 + n202 x1 y1 + n203 x1 y0 + w1 Tlanduse=residential,name=rainbow Nn100,n101,n102,n100 + w2 Tlanduse=residential Nn200,n202,n203,n200 + """ + Then place contains exactly + | object | class | type | + | W1 | landuse | residential | diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py index b7fa1a88..77f279bf 100644 --- a/test/bdd/steps/db_ops.py +++ b/test/bdd/steps/db_ops.py @@ -118,14 +118,15 @@ def assert_db_column(row, column, value, context): if column.startswith('centroid'): fac = float(column[9:]) if column.startswith('centroid*') else 1.0 x, y = value.split(' ') - assert_almost_equal(float(x) * fac, row['cx']) - assert_almost_equal(float(y) * fac, row['cy']) + assert_almost_equal(float(x) * fac, row['cx'], "Bad x coordinate") + assert_almost_equal(float(y) * fac, row['cy'], "Bad y coordinate") elif column == 'geometry': geom = context.osm.parse_geometry(value, context.scene) cur = context.db.cursor() - cur.execute("SELECT ST_Equals(%s, ST_SetSRID(%%s::geometry, 4326))" % geom, - (row['geomtxt'],)) - eq_(cur.fetchone()[0], True) + query = "SELECT ST_Equals(ST_SnapToGrid(%s, 0.00001, 0.00001), ST_SnapToGrid(ST_SetSRID('%s'::geometry, 4326), 0.00001, 0.00001))" % ( + geom, row['geomtxt'],) + cur.execute(query) + eq_(cur.fetchone()[0], True, "(Row %s failed: %s)" % (column, query)) else: eq_(value, str(row[column]), "Row '%s': expected: %s, got: %s" @@ -258,6 +259,7 @@ def check_placex_contents(context, exact): ST_X(centroid) as cx, ST_Y(centroid) as cy FROM placex where %s""" % where, params) + assert_less(0, cur.rowcount, "No rows found for " + row['object']) for res in cur: if exact: @@ -286,6 +288,48 @@ def check_placex_contents(context, exact): context.db.commit() +@then("place contains(?P exactly)?") +def check_placex_contents(context, exact): + cur = context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) + + expected_content = set() + for row in context.table: + nid = NominatimID(row['object']) + where, params = nid.table_select() + cur.execute("""SELECT *, ST_AsText(geometry) as geomtxt + FROM place where %s""" % where, + params) + assert_less(0, cur.rowcount, "No rows found for " + row['object']) + + for res in cur: + if exact: + expected_content.add((res['osm_type'], res['osm_id'], res['class'])) + for h in row.headings: + msg = "%s: %s" % (row['object'], h) + if h in ('name', 'extratags'): + vdict = eval('{' + row[h] + '}') + assert_equals(vdict, res[h], msg) + elif h.startswith('name+'): + assert_equals(res['name'][h[5:]], row[h], msg) + elif h.startswith('extratags+'): + assert_equals(res['extratags'][h[10:]], row[h], msg) + elif h in ('linked_place_id', 'parent_place_id'): + if row[h] == '0': + assert_equals(0, res[h], msg) + elif row[h] == '-': + assert_is_none(res[h], msg) + else: + assert_equals(NominatimID(row[h]).get_place_id(context.db.cursor()), + res[h], msg) + else: + assert_db_column(res, h, row[h], context) + + if exact: + cur.execute('SELECT osm_type, osm_id, class from place') + eq_(expected_content, set([(r[0], r[1], r[2]) for r in cur])) + + context.db.commit() + @then("search_name contains") def check_search_name_contents(context): cur = context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) @@ -294,6 +338,7 @@ def check_search_name_contents(context): pid = NominatimID(row['object']).get_place_id(cur) cur.execute("""SELECT *, ST_X(centroid) as cx, ST_Y(centroid) as cy FROM search_name WHERE place_id = %s""", (pid, )) + assert_less(0, cur.rowcount, "No rows found for " + row['object']) for res in cur: for h in row.headings: diff --git a/test/bdd/steps/osm_data.py b/test/bdd/steps/osm_data.py new file mode 100644 index 00000000..aeca5637 --- /dev/null +++ b/test/bdd/steps/osm_data.py @@ -0,0 +1,33 @@ +import subprocess +import tempfile +import random +import os +from nose.tools import * # for assert functions + +@when(u'loading osm data') +def load_osm_file(context): + + # create a OSM file in /tmp and import it + with tempfile.NamedTemporaryFile(dir='/tmp', suffix='.opl', delete=False) as fd: + fname = fd.name + for line in context.text.splitlines(): + if line.startswith('n') and line.find(' x') < 0: + line += " x%d y%d" % (random.random()*360 - 180, + random.random()*180 - 90) + fd.write(line.encode('utf-8')) + fd.write(b'\n') + + context.nominatim.run_setup_script('import-data', osm_file=fname, + osm2pgsql_cache=300) + + ### reintroduce the triggers/indexes we've lost by having osm2pgsql set up place again + cur = context.db.cursor() + cur.execute("""CREATE TRIGGER place_before_delete BEFORE DELETE ON place + FOR EACH ROW EXECUTE PROCEDURE place_delete()""") + cur.execute("""CREATE TRIGGER place_before_insert BEFORE INSERT ON place + FOR EACH ROW EXECUTE PROCEDURE place_insert()""") + cur.execute("""CREATE UNIQUE INDEX idx_place_osm_unique on place using btree(osm_id,osm_type,class,type)""") + context.db.commit() + + os.remove(fname) + From e2f23e391b9217cbcb85fbc74cecf01f8d974831 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Mon, 28 Nov 2016 23:27:40 +0100 Subject: [PATCH 15/29] add osm2pgsql broken data tests --- test/bdd/osm2pgsql/import/broken.feature | 33 ++++++++++++++++++++++++ test/bdd/steps/db_ops.py | 3 ++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 test/bdd/osm2pgsql/import/broken.feature diff --git a/test/bdd/osm2pgsql/import/broken.feature b/test/bdd/osm2pgsql/import/broken.feature new file mode 100644 index 00000000..aa8d8e35 --- /dev/null +++ b/test/bdd/osm2pgsql/import/broken.feature @@ -0,0 +1,33 @@ +@DB +Feature: Import of objects with broken geometries by osm2pgsql + + Scenario: Import way with double nodes + When loading osm data + """ + n100 x0 y0 + n101 x0 y0.1 + n102 x0.1 y0.2 + w1 Thighway=primary Nn100,n101,n101,n102 + """ + Then place contains + | object | class | type | geometry | + | W1 | highway | primary | 0 0, 0 0.1, 0.1 0.2 | + + @wip + Scenario: Import of ballon areas + When loading osm data + """ + n1 x0 y0 + n2 x0 y0.0001 + n3 x0.00001 y0.0001 + n4 x0.00001 y0 + n5 x-0.00001 y0 + w1 Thighway=unclassified Nn1,n2,n3,n4,n1,n5 + w2 Thighway=unclassified Nn1,n2,n3,n4,n1 + w3 Thighway=unclassified Nn1,n2,n3,n4,n3 + """ + Then place contains + | object | geometrytype | + | W1 | ST_LineString | + | W2 | ST_Polygon | + | W3 | ST_LineString | diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py index 77f279bf..a3cafa01 100644 --- a/test/bdd/steps/db_ops.py +++ b/test/bdd/steps/db_ops.py @@ -296,7 +296,8 @@ def check_placex_contents(context, exact): for row in context.table: nid = NominatimID(row['object']) where, params = nid.table_select() - cur.execute("""SELECT *, ST_AsText(geometry) as geomtxt + cur.execute("""SELECT *, ST_AsText(geometry) as geomtxt, + ST_GeometryType(geometry) as geometrytype FROM place where %s""" % where, params) assert_less(0, cur.rowcount, "No rows found for " + row['object']) From 6f4f19004cef438bd2d4f6e59173ec5e73e934d3 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Tue, 29 Nov 2016 20:08:05 +0100 Subject: [PATCH 16/29] add remianing osm2pgsql import tests --- test/bdd/osm2pgsql/import/relation.feature | 11 + test/bdd/osm2pgsql/import/tags.feature | 552 +++++++++++++++++++++ test/bdd/steps/db_ops.py | 15 +- 3 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 test/bdd/osm2pgsql/import/relation.feature create mode 100644 test/bdd/osm2pgsql/import/tags.feature diff --git a/test/bdd/osm2pgsql/import/relation.feature b/test/bdd/osm2pgsql/import/relation.feature new file mode 100644 index 00000000..7010779e --- /dev/null +++ b/test/bdd/osm2pgsql/import/relation.feature @@ -0,0 +1,11 @@ +@DB +Feature: Import of relations by osm2pgsql + Testing specific relation problems related to members. + + Scenario: Don't import empty waterways + When loading osm data + """ + n1 Tamenity=prison,name=foo + r1 Ttype=waterway,waterway=river,name=XZ Mn1@ + """ + Then place has no entry for R1 diff --git a/test/bdd/osm2pgsql/import/tags.feature b/test/bdd/osm2pgsql/import/tags.feature new file mode 100644 index 00000000..28ae34cb --- /dev/null +++ b/test/bdd/osm2pgsql/import/tags.feature @@ -0,0 +1,552 @@ +@DB +Feature: Tag evaluation + Tests if tags are correctly imported into the place table + + Scenario Outline: Name tags + When loading osm data + """ + n1 Thighway=yes,=Foo + """ + Then place contains + | object | name | + | N1 | '' : 'Foo' | + + Examples: + | nametag | + | ref | + | int_ref | + | nat_ref | + | reg_ref | + | loc_ref | + | old_ref | + | iata | + | icao | + | pcode:1 | + | pcode:2 | + | pcode:3 | + | name | + | name:de | + | name:bt-BR | + | int_name | + | int_name:xxx | + | nat_name | + | nat_name:fr | + | reg_name | + | reg_name:1 | + | loc_name | + | loc_name:DE | + | old_name | + | old_name:v1 | + | alt_name | + | alt_name:dfe | + | alt_name_1 | + | official_name | + | short_name | + | short_name:CH | + | addr:housename | + | brand | + + Scenario: operator only for shops and amenities + When loading osm data + """ + n1 Thighway=yes,operator=Foo,name=null + n2 Tshop=grocery,operator=Foo + n3 Tamenity=hospital,operator=Foo + n4 Ttourism=hotel,operator=Foo + """ + Then place contains + | object | name | + | N1 | 'name' : 'null' | + | N2 | 'operator' : 'Foo' | + | N3 | 'operator' : 'Foo' | + | N4 | 'operator' : 'Foo' | + + Scenario Outline: Ignored name tags + When loading osm data + """ + n1 Thighway=yes,=Foo,name=real + """ + Then place contains + | object | name | + | N1 | 'name' : 'real' | + + Examples: + | nametag | + | name_de | + | Name | + | ref:de | + | ref_de | + | my:ref | + | br:name | + | name:prefix | + | name:source | + + Scenario: Special character in name tag + When loading osm data + """ + n1 Thighway=yes,name:%20%de=Foo,name=real1 + n2 Thighway=yes,name:%a%de=Foo,name=real2 + n3 Thighway=yes,name:%9%de=Foo,name:\\=real3 + """ + Then place contains + | object | name | + | N1 | 'name: de' : 'Foo', 'name' : 'real1' | + | N2 | 'name: de' : 'Foo', 'name' : 'real2' | + | N3 | 'name: de' : 'Foo', 'name:\\\\' : 'real3' | + + Scenario Outline: Included places + When loading osm data + """ + n1 T=,name=real + """ + Then place contains + | object | name | + | N1 | 'name' : 'real' | + + Examples: + | key | value | + | emergency | phone | + | tourism | information | + | historic | castle | + | military | barracks | + | natural | water | + | highway | residential | + | aerialway | station | + | aeroway | way | + | boundary | administrative | + | craft | butcher | + | leisure | playground | + | office | bookmaker | + | railway | rail | + | shop | bookshop | + | waterway | stream | + | landuse | cemetry | + | man_made | tower | + | mountain_pass | yes | + + Scenario Outline: Bridges and Tunnels take special name tags + When loading osm data + """ + n1 Thighway=road,=yes,name=Rd,:name=My + n2 Thighway=road,=yes,name=Rd + """ + Then place contains + | object | type | name | + | N1:highway | road | 'name' : 'Rd' | + | N1: | yes | 'name' : 'My' | + | N2:highway | road | 'name' : 'Rd' | + And place has no entry for N2: + + Examples: + | key | + | bridge | + | tunnel | + + Scenario Outline: Excluded places + When loading osm data + """ + n1 T=,name=real + n2 Thighway=motorway,name=To%20%Hell + """ + Then place has no entry for N1 + + Examples: + | key | value | + | emergency | yes | + | emergency | no | + | tourism | yes | + | tourism | no | + | historic | yes | + | historic | no | + | military | yes | + | military | no | + | natural | yes | + | natural | no | + | highway | no | + | highway | turning_circle | + | highway | mini_roundabout | + | highway | noexit | + | highway | crossing | + | aerialway | no | + | aerialway | pylon | + | man_made | survey_point | + | man_made | cutline | + | aeroway | no | + | amenity | no | + | bridge | no | + | craft | no | + | leisure | no | + | office | no | + | railway | no | + | railway | level_crossing | + | shop | no | + | tunnel | no | + | waterway | riverbank | + + Scenario Outline: Some tags only are included when named + When loading osm data + """ + n1 T= + n2 T=,name=To%20%Hell + n3 T=,ref=123 + """ + Then place contains exactly + | object | class | type | + | N2 | | | + + Examples: + | key | value | + | landuse | residential | + | natural | meadow | + | highway | traffic_signals | + | highway | service | + | highway | cycleway | + | highway | path | + | highway | footway | + | highway | steps | + | highway | bridleway | + | highway | track | + | highway | byway | + | highway | motorway_link | + | highway | primary_link | + | highway | trunk_link | + | highway | secondary_link | + | highway | tertiary_link | + | railway | rail | + | boundary | administrative | + | waterway | stream | + + Scenario: Footways are not included if they are sidewalks + When loading osm data + """ + n2 Thighway=footway,name=To%20%Hell,footway=sidewalk + n23 Thighway=footway,name=x + """ + Then place has no entry for N2 + + Scenario: named junctions are included if there is no other tag + When loading osm data + """ + n1 Tjunction=yes + n2 Thighway=secondary,junction=roundabout,name=To-Hell + n3 Tjunction=yes,name=Le%20%Croix + """ + Then place has no entry for N1 + And place has no entry for N2:junction + And place contains + | object | class | type | + | N3 | junction | yes | + + Scenario: Boundary with place tag + When loading osm data + """ + n200 x0 y0 + n201 x0 y1 + n202 x1 y1 + n203 x1 y0 + w2 Tboundary=administrative,place=city,name=Foo Nn200,n201,n202,n203,n200 + w4 Tboundary=administrative,place=island,name=Foo Nn200,n201,n202,n203,n200 + w20 Tplace=city,name=ngng Nn200,n201,n202,n203,n200 + w40 Tplace=city,boundary=statistical,name=BB Nn200,n201,n202,n203,n200 + """ + Then place contains + | object | class | extratags | type | + | W2 | boundary | 'place' : 'city' | administrative | + | W4:boundary | boundary | - | administrative | + | W4:place | place | - | island | + | W20 | place | - | city | + | W40:boundary | boundary | - | statistical | + | W40:place | place | - | city | + And place has no entry for W2:place + + Scenario Outline: Tags that describe a house + When loading osm data + """ + n100 T= + n999 Tamenity=prison,= + """ + Then place contains exactly + | object | class | type | + | N100 | place | house | + | N999 | amenity | prison | + + Examples: + | key | value | + | addr:housename | My%20%Mansion | + | addr:housenumber | 456 | + | addr:conscriptionnumber | 4 | + | addr:streetnumber | 4568765 | + + Scenario: Only named with no other interesting tag + When loading osm data + """ + n1 Tlanduse=meadow + n2 Tlanduse=residential,name=important + n3 Tlanduse=residential,name=important,place=hamlet + """ + Then place contains + | object | class | type | + | N2 | landuse | residential | + | N3 | place | hamlet | + And place has no entry for N1 + And place has no entry for N3:landuse + + Scenario Outline: Import of postal codes + When loading osm data + """ + n10 Thighway=secondary,= + n11 T= + """ + Then place contains + | object | class | type | postcode | + | N10 | highway | secondary | | + | N11 | place | postcode | | + And place has no entry for N10:place + + Examples: + | key | value | + | postal_code | 45736 | + | postcode | xxx | + | addr:postcode | 564 | + | tiger:zip_left | 00011 | + | tiger:zip_right | 09123 | + + Scenario: Import of street and place + When loading osm data + """ + n10 Tamenity=hospital,addr:street=Foo%20%St + n20 Tamenity=hospital,addr:place=Foo%20%Town + """ + Then place contains + | object | class | type | street | addr_place | + | N10 | amenity | hospital | Foo St | None | + | N20 | amenity | hospital | - | Foo Town | + + + Scenario Outline: Import of country + When loading osm data + """ + n10 Tplace=village,= + """ + Then place contains + | object | class | type | country_code | + | N10 | place | village | | + + Examples: + | key | value | + | country_code | us | + | ISO3166-1 | XX | + | is_in:country_code | __ | + | addr:country | .. | + | addr:country_code | cv | + + Scenario Outline: Ignore country codes with wrong length + When loading osm data + """ + n10 Tplace=village,country_code= + """ + Then place contains + | object | class | type | country_code | + | N10 | place | village | - | + + Examples: + | value | + | X | + | x | + | ger | + | dkeufr | + | d%20%e | + + Scenario: Import of house numbers + When loading osm data + """ + n10 Tbuilding=yes,addr:housenumber=4b + n11 Tbuilding=yes,addr:conscriptionnumber=003 + n12 Tbuilding=yes,addr:streetnumber=2345 + n13 Tbuilding=yes,addr:conscriptionnumber=3,addr:streetnumber=111 + """ + Then place contains + | object | class | type | housenumber | + | N10 | building | yes | 4b | + | N11 | building | yes | 003 | + | N12 | building | yes | 2345 | + | N13 | building | yes | 3/111 | + + Scenario: Import of address interpolations + When loading osm data + """ + n10 Taddr:interpolation=odd + n11 Taddr:housenumber=10,addr:interpolation=odd + n12 Taddr:interpolation=odd,addr:housenumber=23 + """ + Then place contains + | object | class | type | housenumber | + | N10 | place | houses | odd | + | N11 | place | houses | odd | + | N12 | place | houses | odd | + + Scenario: Shorten tiger:county tags + When loading osm data + """ + n10 Tplace=village,tiger:county=Feebourgh%2c%%20%AL + n11 Tplace=village,addr:state=Alabama,tiger:county=Feebourgh%2c%%20%AL + n12 Tplace=village,tiger:county=Feebourgh + """ + Then place contains + | object | class | type | isin | + | N10 | place | village | Feebourgh county | + | N11 | place | village | Alabama,Feebourgh county | + | N12 | place | village | Feebourgh county | + + Scenario Outline: Import of address tags + When loading osm data + """ + n10 Tplace=village,= + """ + Then place contains + | object | class | type | isin | + | N10 | place | village | | + + Examples: + | key | value | + | is_in:country | Xanadu | + | addr:suburb | hinein | + | addr:city | Sydney | + | addr:state | Jura | + + Scenario: Import of isin tags with space + When loading osm data + """ + n10 Tplace=village,is_in=Stockholm%2c%%20%Sweden + n11 Tplace=village,addr:county=le%20%havre + """ + Then place contains + | object | class | type | isin | + | N10 | place | village | Stockholm, Sweden | + | N11 | place | village | le havre | + + Scenario: Import of admin level + When loading osm data + """ + n10 Tamenity=hospital,admin_level=3 + n11 Tamenity=hospital,admin_level=b + n12 Tamenity=hospital + n13 Tamenity=hospital,admin_level=3.0 + """ + Then place contains + | object | class | type | admin_level | + | N10 | amenity | hospital | 3 | + | N11 | amenity | hospital | 100 | + | N12 | amenity | hospital | 100 | + | N13 | amenity | hospital | 3 | + + Scenario Outline: Import of extra tags + When loading osm data + """ + n10 Ttourism=hotel,=foo + """ + Then place contains + | object | class | type | extratags | + | N10 | tourism | hotel | '' : 'foo' | + + Examples: + | key | + | tracktype | + | traffic_calming | + | service | + | cuisine | + | capital | + | dispensing | + | religion | + | denomination | + | sport | + | internet_access | + | lanes | + | surface | + | smoothness | + | width | + | est_width | + | incline | + | opening_hours | + | collection_times | + | service_times | + | disused | + | wheelchair | + | sac_scale | + | trail_visibility | + | mtb:scale | + | mtb:description | + | wood | + | drive_in | + | access | + | vehicle | + | bicyle | + | foot | + | goods | + | hgv | + | motor_vehicle | + | motor_car | + | access:foot | + | contact:phone | + | drink:mate | + | oneway | + | date_on | + | date_off | + | day_on | + | day_off | + | hour_on | + | hour_off | + | maxweight | + | maxheight | + | maxspeed | + | disused | + | toll | + | charge | + | population | + | description | + | image | + | attribution | + | fax | + | email | + | url | + | website | + | phone | + | real_ale | + | smoking | + | food | + | camera | + | brewery | + | locality | + | wikipedia | + | wikipedia:de | + | wikidata | + | name:prefix | + | name:botanical | + | name:etymology:wikidata | + + Scenario: buildings + When loading osm data + """ + n10 Ttourism=hotel,building=yes + n11 Tbuilding=house + n12 Tbuilding=shed,addr:housenumber=1 + n13 Tbuilding=yes,name=Das-Haus + n14 Tbuilding=yes,addr:postcode=12345 + """ + Then place contains + | object | class | type | + | N10 | tourism | hotel | + | N12 | building| yes | + | N13 | building| yes | + | N14 | building| yes | + And place has no entry for N10:building + And place has no entry for N11 + + Scenario: complete node entry + When loading osm data + """ + n290393920 Taddr:city=Perpignan,addr:country=FR,addr:housenumber=43\,addr:postcode=66000,addr:street=Rue%20%Pierre%20%Constant%20%d`Ivry,source=cadastre-dgi-fr%20%source%20%:%20%Direction%20%Générale%20%des%20%Impôts%20%-%20%Cadastre%20%;%20%mise%20%à%20%jour%20%:2008 + """ + Then place contains + | object | class | type | housenumber | + | N290393920 | place | house| 43\ | diff --git a/test/bdd/steps/db_ops.py b/test/bdd/steps/db_ops.py index a3cafa01..3c5c5632 100644 --- a/test/bdd/steps/db_ops.py +++ b/test/bdd/steps/db_ops.py @@ -127,6 +127,8 @@ def assert_db_column(row, column, value, context): geom, row['geomtxt'],) cur.execute(query) eq_(cur.fetchone()[0], True, "(Row %s failed: %s)" % (column, query)) + elif value == '-': + assert_is_none(row[column], "Row %s" % column) else: eq_(value, str(row[column]), "Row '%s': expected: %s, got: %s" @@ -308,8 +310,11 @@ def check_placex_contents(context, exact): for h in row.headings: msg = "%s: %s" % (row['object'], h) if h in ('name', 'extratags'): - vdict = eval('{' + row[h] + '}') - assert_equals(vdict, res[h], msg) + if row[h] == '-': + assert_is_none(res[h], msg) + else: + vdict = eval('{' + row[h] + '}') + assert_equals(vdict, res[h], msg) elif h.startswith('name+'): assert_equals(res['name'][h[5:]], row[h], msg) elif h.startswith('extratags+'): @@ -403,12 +408,12 @@ def check_location_property_osmline(context, oid, neg): eq_(todo, []) -@then("placex has no entry for (?P.*)") -def check_placex_has_entry(context, oid): +@then("(?P
placex|place) has no entry for (?P.*)") +def check_placex_has_entry(context, table, oid): cur = context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) nid = NominatimID(oid) where, params = nid.table_select() - cur.execute("SELECT * FROM placex where %s" % where, params) + cur.execute("SELECT * FROM %s where %s" % (table, where), params) eq_(0, cur.rowcount) context.db.commit() From 80a74181e463efeb071a3904346107d05e88a7a6 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Tue, 29 Nov 2016 21:34:31 +0100 Subject: [PATCH 17/29] add osm2pgsql update tests --- test/bdd/environment.py | 4 +- test/bdd/osm2pgsql/update/relation.feature | 126 +++++++++++++++++++++ test/bdd/osm2pgsql/update/simple.feature | 26 +++++ test/bdd/steps/osm_data.py | 29 ++++- 4 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 test/bdd/osm2pgsql/update/relation.feature create mode 100644 test/bdd/osm2pgsql/update/simple.feature diff --git a/test/bdd/environment.py b/test/bdd/environment.py index 29c6675c..3af3fb58 100644 --- a/test/bdd/environment.py +++ b/test/bdd/environment.py @@ -125,8 +125,8 @@ class NominatimEnvironment(object): def run_setup_script(self, *args, **kwargs): self.run_nominatim_script('setup', *args, **kwargs) - def run_update_script(self, *args): - self.run_nominatim_script('update', *args) + def run_update_script(self, *args, **kwargs): + self.run_nominatim_script('update', *args, **kwargs) def run_nominatim_script(self, script, *args, **kwargs): cmd = [os.path.join(self.build_dir, 'utils', '%s.php' % script)] diff --git a/test/bdd/osm2pgsql/update/relation.feature b/test/bdd/osm2pgsql/update/relation.feature new file mode 100644 index 00000000..88889f9a --- /dev/null +++ b/test/bdd/osm2pgsql/update/relation.feature @@ -0,0 +1,126 @@ +@DB +Feature: Update of relations by osm2pgsql + Testing relation update by osm2pgsql. + + Scenario: Remove all members of a relation + When loading osm data + """ + n1 Tamenity=prison,name=foo + n200 x0 y0 + n201 x0 y0.0001 + n202 x0.0001 y0.0001 + n203 x0.0001 y0 + w2 Tref=45' Nn200,n201,n202,n203,n200 + r1 Ttype=multipolygon,tourism=hotel,name=XZ' Mw2@ + """ + Then place contains + | object | class | type | name + | R1 | tourism | hotel | 'name' : 'XZ' + When updating osm data + """ + r1 Ttype=multipolygon,tourism=hotel,name=XZ Mn1@ + """ + Then place has no entry for R1 + + + Scenario: Change type of a relation + When loading osm data + """ + n200 x0 y0 + n201 x0 y0.0001 + n202 x0.0001 y0.0001 + n203 x0.0001 y0 + w2 Tref=45 Nn200,n201,n202,n203,n200 + r1 Ttype=multipolygon,tourism=hotel,name=XZ Mw2@ + """ + Then place contains + | object | class | type | name + | R1 | tourism | hotel | 'name' : 'XZ' + When updating osm data + """ + r1 Ttype=multipolygon,amenity=prison,name=XZ Mw2@ + """ + Then place has no entry for R1:tourism + And place contains + | object | class | type | name + | R1 | amenity | prison | 'name' : 'XZ' + + Scenario: Change name of a relation + When loading osm data + """ + n200 x0 y0 + n201 x0 y0.0001 + n202 x0.0001 y0.0001 + n203 x0.0001 y0 + w2 Tref=45 Nn200,n201,n202,n203,n200 + r1 Ttype=multipolygon,tourism=hotel,name=AB Mw2@ + """ + Then place contains + | object | class | type | name + | R1 | tourism | hotel | 'name' : 'AB' + When updating osm data + """ + r1 Ttype=multipolygon,tourism=hotel,name=XY Mw2@ + """ + Then place contains + | object | class | type | name + | R1 | tourism | hotel | 'name' : 'XZ' + + Scenario: Change type of a relation into something unknown + When loading osm data + """ + n200 x0 y0 + n201 x0 y0.0001 + n202 x0.0001 y0.0001 + n203 x0.0001 y0 + w2 Tref=45 Nn200,n201,n202,n203,n200 + r1 Ttype=multipolygon,tourism=hotel,name=XY Mw2@ + """ + Then place contains + | object | class | type | name + | R1 | tourism | hotel | 'name' : 'XZ' + When updating osm data + """ + r1 Ttype=multipolygon,amenities=prison,name=XY Mw2@ + """ + Then place has no entry for R1 + + Scenario: Type tag is removed + When loading osm data + """ + n200 x0 y0 + n201 x0 y0.0001 + n202 x0.0001 y0.0001 + n203 x0.0001 y0 + w2 Tref=45 Nn200,n201,n202,n203,n200 + r1 Ttype=multipolygon,tourism=hotel,name=XY Mw2@ + """ + Then place contains + | object | class | type | name + | R1 | tourism | hotel | 'name' : 'XZ' + When updating osm data + """ + r1 Ttourism=hotel,name=XY Mw2@ + """ + Then place has no entry for R1 + + @wip + Scenario: Type tag is renamed to something unknown + When loading osm data + """ + n200 x0 y0 + n201 x0 y0.0001 + n202 x0.0001 y0.0001 + n203 x0.0001 y0 + w2 Tref=45 Nn200,n201,n202,n203,n200 + r1 Ttype=multipolygon,tourism=hotel,name=XY Mw2@ + """ + Then place contains + | object | class | type | name + | R1 | tourism | hotel | 'name' : 'XZ' + When updating osm data + """ + r1 Ttype=multipolygonn,tourism=hotel,name=XY Mw2@ + """ + Then place has no entry for R1 + diff --git a/test/bdd/osm2pgsql/update/simple.feature b/test/bdd/osm2pgsql/update/simple.feature new file mode 100644 index 00000000..33c21039 --- /dev/null +++ b/test/bdd/osm2pgsql/update/simple.feature @@ -0,0 +1,26 @@ +@DB +Feature: Update of simple objects by osm2pgsql + Testing basic update functions of osm2pgsql. + + Scenario: Import object with two main tags + When loading osm data + """ + n1 Ttourism=hotel,amenity=restaurant,name=foo + n2 Tplace=locality,name=spotty + """ + Then place contains + | object | type | name + | N1:tourism | hotel | 'name' : 'foo' + | N1:amenity | restaurant | 'name' : 'foo' + | N2:place | locality | 'name' : 'spotty' + When updating osm data + """ + n1 dV Ttourism=hotel,name=foo + n2 dD + """ + Then place has no entry for N1:amenity + And place has no entry for N2 + And place contains + | object | class | type | name + | N1:tourism | tourism | hotel | 'name' : 'foo' + diff --git a/test/bdd/steps/osm_data.py b/test/bdd/steps/osm_data.py index aeca5637..926fb9ab 100644 --- a/test/bdd/steps/osm_data.py +++ b/test/bdd/steps/osm_data.py @@ -12,8 +12,8 @@ def load_osm_file(context): fname = fd.name for line in context.text.splitlines(): if line.startswith('n') and line.find(' x') < 0: - line += " x%d y%d" % (random.random()*360 - 180, - random.random()*180 - 90) + line += " x%d y%d" % (random.random() * 360 - 180, + random.random() * 180 - 90) fd.write(line.encode('utf-8')) fd.write(b'\n') @@ -31,3 +31,28 @@ def load_osm_file(context): os.remove(fname) +@when(u'updating osm data') +def update_from_osm_file(context): + context.nominatim.run_setup_script('create-functions', 'create-partition-functions') + + cur = context.db.cursor() + cur.execute("""insert into placex (osm_type, osm_id, class, type, name, + admin_level, housenumber, street, addr_place, isin, postcode, + country_code, extratags, geometry) select * from place""") + context.db.commit() + context.nominatim.run_setup_script('index', 'index-noanalyse') + context.nominatim.run_setup_script('create-functions', 'create-partition-functions', + 'enable-diff-updates') + + # create a OSM file in /tmp and import it + with tempfile.NamedTemporaryFile(dir='/tmp', suffix='.opl', delete=False) as fd: + fname = fd.name + for line in context.text.splitlines(): + if line.startswith('n') and line.find(' x') < 0: + line += " x%d y%d" % (random.random() * 360 - 180, + random.random() * 180 - 90) + fd.write(line.encode('utf-8')) + fd.write(b'\n') + + context.nominatim.run_update_script(import_diff=fname) + os.remove(fname) From b75aadfb6b5a8755b4f040eb1d2e36a6fa7fcbb7 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Tue, 29 Nov 2016 22:19:59 +0100 Subject: [PATCH 18/29] more backslash tests --- test/bdd/osm2pgsql/import/tags.feature | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/bdd/osm2pgsql/import/tags.feature b/test/bdd/osm2pgsql/import/tags.feature index 28ae34cb..0923e47d 100644 --- a/test/bdd/osm2pgsql/import/tags.feature +++ b/test/bdd/osm2pgsql/import/tags.feature @@ -87,12 +87,14 @@ Feature: Tag evaluation n1 Thighway=yes,name:%20%de=Foo,name=real1 n2 Thighway=yes,name:%a%de=Foo,name=real2 n3 Thighway=yes,name:%9%de=Foo,name:\\=real3 + n4 Thighway=yes,name:%9%de=Foo,name:\=real3 """ Then place contains | object | name | | N1 | 'name: de' : 'Foo', 'name' : 'real1' | | N2 | 'name: de' : 'Foo', 'name' : 'real2' | | N3 | 'name: de' : 'Foo', 'name:\\\\' : 'real3' | + | N4 | 'name: de' : 'Foo', 'name:\\' : 'real3' | Scenario Outline: Included places When loading osm data From b9a58b8f24e914e976cfe6ed84144fcce5eb8762 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Sat, 17 Dec 2016 17:33:44 +0100 Subject: [PATCH 19/29] add simple direct API search tests API tests now no longer require a running Apache installation, instead the website php scripts are called directly using the appropriate enivronment. --- test/bdd/environment.py | 7 +- test/bdd/osm2pgsql/import/tags.feature | 4 +- test/bdd/steps/queries.py | 190 ++++++++++++++++++++- test/bdd/steps/results.py | 33 ---- test/bdd/website/search/simple.feature | 221 +++++++++++++++++++++++++ 5 files changed, 416 insertions(+), 39 deletions(-) delete mode 100644 test/bdd/steps/results.py create mode 100644 test/bdd/website/search/simple.feature diff --git a/test/bdd/environment.py b/test/bdd/environment.py index 3af3fb58..69d93bd8 100644 --- a/test/bdd/environment.py +++ b/test/bdd/environment.py @@ -16,6 +16,7 @@ userconfig = { 'KEEP_TEST_DB' : False, 'TEMPLATE_DB' : 'test_template_nominatim', 'TEST_DB' : 'test_nominatim', + 'API_TEST_DB' : 'test_api_nominatim', 'TEST_SETTINGS_FILE' : '/tmp/nominatim_settings.php' } @@ -29,6 +30,7 @@ class NominatimEnvironment(object): self.build_dir = os.path.abspath(config['BUILDDIR']) self.template_db = config['TEMPLATE_DB'] self.test_db = config['TEST_DB'] + self.api_test_db = config['API_TEST_DB'] self.local_settings_file = config['TEST_SETTINGS_FILE'] self.reuse_template = not config['REMOVE_TEMPLATE'] self.keep_scenario_db = config['KEEP_TEST_DB'] @@ -98,7 +100,8 @@ class NominatimEnvironment(object): 'create-partition-tables', 'create-partition-functions', 'load-data', 'create-search-indices') - + def setup_api_db(self, context): + self.write_nominatim_config(self.api_test_db) def setup_db(self, context): self.setup_template_db() @@ -213,6 +216,8 @@ def after_all(context): def before_scenario(context, scenario): if 'DB' in context.tags: context.nominatim.setup_db(context) + elif 'APIDB' in context.tags: + context.nominatim.setup_api_db(context) context.scene = None def after_scenario(context, scenario): diff --git a/test/bdd/osm2pgsql/import/tags.feature b/test/bdd/osm2pgsql/import/tags.feature index 0923e47d..d81b6c72 100644 --- a/test/bdd/osm2pgsql/import/tags.feature +++ b/test/bdd/osm2pgsql/import/tags.feature @@ -87,14 +87,14 @@ Feature: Tag evaluation n1 Thighway=yes,name:%20%de=Foo,name=real1 n2 Thighway=yes,name:%a%de=Foo,name=real2 n3 Thighway=yes,name:%9%de=Foo,name:\\=real3 - n4 Thighway=yes,name:%9%de=Foo,name:\=real3 + n4 Thighway=yes,name:%9%de=Foo,name=rea\l3 """ Then place contains | object | name | | N1 | 'name: de' : 'Foo', 'name' : 'real1' | | N2 | 'name: de' : 'Foo', 'name' : 'real2' | | N3 | 'name: de' : 'Foo', 'name:\\\\' : 'real3' | - | N4 | 'name: de' : 'Foo', 'name:\\' : 'real3' | + | N4 | 'name: de' : 'Foo', 'name' : 'rea\\l3' | Scenario Outline: Included places When loading osm data diff --git a/test/bdd/steps/queries.py b/test/bdd/steps/queries.py index f37f7e7b..c62b8a57 100644 --- a/test/bdd/steps/queries.py +++ b/test/bdd/steps/queries.py @@ -6,20 +6,98 @@ import json import os +import io +import re +from tidylib import tidy_document +import xml.etree.ElementTree as ET import subprocess +from urllib.parse import urlencode from collections import OrderedDict from nose.tools import * # for assert functions +BASE_SERVER_ENV = { + 'HTTP_HOST' : 'localhost', + 'HTTP_USER_AGENT' : 'Mozilla/5.0 (X11; Linux x86_64; rv:51.0) Gecko/20100101 Firefox/51.0', + 'HTTP_ACCEPT' : 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'HTTP_ACCEPT_LANGUAGE' : 'en,de;q=0.5', + 'HTTP_ACCEPT_ENCODING' : 'gzip, deflate', + 'HTTP_CONNECTION' : 'keep-alive', + 'SERVER_SIGNATURE' : '
Nominatim BDD Tests
', + 'SERVER_SOFTWARE' : 'Nominatim test', + 'SERVER_NAME' : 'localhost', + 'SERVER_ADDR' : '127.0.1.1', + 'SERVER_PORT' : '80', + 'REMOTE_ADDR' : '127.0.0.1', + 'DOCUMENT_ROOT' : '/var/www', + 'REQUEST_SCHEME' : 'http', + 'CONTEXT_PREFIX' : '/', + 'SERVER_ADMIN' : 'webmaster@localhost', + 'REMOTE_PORT' : '49319', + 'GATEWAY_INTERFACE' : 'CGI/1.1', + 'SERVER_PROTOCOL' : 'HTTP/1.1', + 'REQUEST_METHOD' : 'GET', + 'REDIRECT_STATUS' : 'CGI' +} + + +def compare(operator, op1, op2): + if operator == 'less than': + return op1 < op2 + elif operator == 'more than': + return op1 > op2 + elif operator == 'exactly': + return op1 == op2 + elif operator == 'at least': + return op1 >= op2 + elif operator == 'at most': + return op1 <= op2 + else: + raise Exception("unknown operator '%s'" % operator) + + class SearchResponse(object): def __init__(self, page, fmt='json', errorcode=200): self.page = page self.format = fmt self.errorcode = errorcode - getattr(self, 'parse_' + fmt)() + self.result = [] + self.header = dict() + + if errorcode == 200: + getattr(self, 'parse_' + fmt)() def parse_json(self): - self.result = json.JSONDecoder(object_pairs_hook=OrderedDict).decode(self.page) + m = re.fullmatch(r'([\w$][^(]*)\((.*)\)', self.page) + if m is None: + code = self.page + else: + code = m.group(2) + self.header['json_func'] = m.group(1) + self.result = json.JSONDecoder(object_pairs_hook=OrderedDict).decode(code) + + def parse_html(self): + content, errors = tidy_document(self.page, + options={'char-encoding' : 'utf8'}) + #eq_(len(errors), 0 , "Errors found in HTML document:\n%s" % errors) + + b = content.find('nominatim_results =') + e = content.find('') + content = content[b:e] + b = content.find('[') + e = content.rfind(']') + + self.result = json.JSONDecoder(object_pairs_hook=OrderedDict).decode(content[b:e+1]) + + def parse_xml(self): + et = ET.fromstring(self.page) + + self.header = dict(et.attrib) + + + for child in et: + assert_equal(child.tag, "place") + self.result.append(dict(child.attrib)) def match_row(self, row): if 'ID' in row.headings: @@ -64,6 +142,112 @@ def query_cmd(context, query, dups): stdout=subprocess.PIPE, stderr=subprocess.PIPE) (outp, err) = proc.communicate() - assert_equals (0, proc.returncode, "query.php failed with message: %s" % err) + assert_equals (0, proc.returncode, "query.php failed with message: %s\noutput: %s" % (err, outp)) context.response = SearchResponse(outp.decode('utf-8'), 'json') + + +@when(u'sending (?P\S+ )?search query "(?P.*)"') +def website_search_request(context, fmt, query): + env = BASE_SERVER_ENV + + params = { 'q' : query } + if fmt is not None: + params['format'] = fmt.strip() + if context.table: + if context.table.headings[0] == 'param': + for line in context.table: + params[line['param']] = line['value'] + else: + for h in context.table.headings: + params[h] = context.table[0][h] + env['QUERY_STRING'] = urlencode(params) + + env['REQUEST_URI'] = '/search.php?' + env['QUERY_STRING'] + env['SCRIPT_NAME'] = '/search.php' + env['CONTEXT_DOCUMENT_ROOT'] = os.path.join(context.nominatim.build_dir, 'website') + env['SCRIPT_FILENAME'] = os.path.join(context.nominatim.build_dir, 'website', 'search.php') + env['NOMINATIM_SETTINGS'] = context.nominatim.local_settings_file + + cmd = [ '/usr/bin/php-cgi', env['SCRIPT_FILENAME']] + for k,v in params.items(): + cmd.append("%s=%s" % (k, v)) + + proc = subprocess.Popen(cmd, cwd=context.nominatim.build_dir, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + (outp, err) = proc.communicate() + + assert_equals(0, proc.returncode, + "query.php failed with message: %s\noutput: %s" % (err, outp)) + + assert_equals(0, len(err), "Unexpected PHP error: %s" % (err)) + + outp = outp.decode('utf-8') + + if outp.startswith('Status: '): + status = int(outp[8:11]) + else: + status = 200 + + content_start = outp.find('\r\n\r\n') + assert_less(11, content_start) + + if fmt is None: + outfmt = 'html' + elif fmt == 'jsonv2 ': + outfmt = 'json' + else: + outfmt = fmt.strip() + + context.response = SearchResponse(outp[content_start + 4:], outfmt, status) + + +@step(u'(?Pless than|more than|exactly|at least|at most) (?P\d+) results? (?:is|are) returned') +def validate_result_number(context, operator, number): + eq_(context.response.errorcode, 200) + numres = len(context.response.result) + ok_(compare(operator, numres, int(number)), + "Bad number of results: expected %s %s, got %d." % (operator, number, numres)) + +@then(u'a HTTP (?P\d+) is returned') +def check_http_return_status(context, status): + eq_(context.response.errorcode, int(status)) + +@then(u'the result is valid (?P\w+)') +def step_impl(context, fmt): + eq_(context.response.format, fmt) + +@then(u'result header contains') +def check_header_attr(context): + for line in context.table: + assert_is_not_none(re.fullmatch(line['value'], context.response.header[line['attr']]), + "attribute '%s': expected: '%s', got '%s'" + % (line['attr'], line['value'], + context.response.header[line['attr']])) + +@then(u'result header has (?Pnot )?attributes (?P.*)') +def check_header_no_attr(context, neg, attrs): + for attr in attrs.split(','): + if neg: + assert_not_in(attr, context.response.header) + else: + assert_in(attr, context.response.header) + +@then(u'results contain') +def step_impl(context): + context.execute_steps("then at least 1 result is returned") + + for line in context.table: + context.response.match_row(line) + +@then(u'result (?P\d+) has (?Pnot )?attributes (?P.*)') +def validate_attributes(context, lid, neg, attrs): + context.execute_steps("then at least %s result is returned" % lid) + + for attr in attrs.split(','): + if neg: + assert_not_in(attr, context.response.result[int(lid)]) + else: + assert_in(attr, context.response.result[int(lid)]) + diff --git a/test/bdd/steps/results.py b/test/bdd/steps/results.py deleted file mode 100644 index 87fefd4a..00000000 --- a/test/bdd/steps/results.py +++ /dev/null @@ -1,33 +0,0 @@ -""" Steps that check results. -""" - -from nose.tools import * # for assert functions - -def compare(operator, op1, op2): - if operator == 'less than': - return op1 < op2 - elif operator == 'more than': - return op1 > op2 - elif operator == 'exactly': - return op1 == op2 - elif operator == 'at least': - return op1 >= op2 - elif operator == 'at most': - return op1 <= op2 - else: - raise Exception("unknown operator '%s'" % operator) - -@step(u'(?Pless than|more than|exactly|at least|at most) (?P\d+) results? (?:is|are) returned') -def validate_result_number(context, operator, number): - numres = len(context.response.result) - ok_(compare(operator, numres, int(number)), - "Bad number of results: expected %s %s, got %d." % (operator, number, numres)) - - -@then(u'results contain') -def step_impl(context): - context.execute_steps("then at least 1 result is returned") - - for line in context.table: - context.response.match_row(line) - diff --git a/test/bdd/website/search/simple.feature b/test/bdd/website/search/simple.feature new file mode 100644 index 00000000..4d77eac4 --- /dev/null +++ b/test/bdd/website/search/simple.feature @@ -0,0 +1,221 @@ +@APIDB +Feature: Simple Tests + Simple tests for internal server errors and response format. + + Scenario Outline: Testing different parameters + When sending search query "Hamburg" + | param | value | + | | | + Then at least 1 result is returned + When sending html search query "Hamburg" + | param | value | + | | | + Then at least 1 result is returned + When sending xml search query "Hamburg" + | param | value | + | | | + Then at least 1 result is returned + When sending json search query "Hamburg" + | param | value | + | | | + Then at least 1 result is returned + When sending jsonv2 search query "Hamburg" + | param | value | + | | | + Then at least 1 result is returned + + Examples: + | parameter | value | + | addressdetails | 1 | + | addressdetails | 0 | + | polygon | 1 | + | polygon | 0 | + | polygon_text | 1 | + | polygon_text | 0 | + | polygon_kml | 1 | + | polygon_kml | 0 | + | polygon_geojson | 1 | + | polygon_geojson | 0 | + | polygon_svg | 1 | + | polygon_svg | 0 | + | accept-language | de,en | + | countrycodes | de | + | bounded | 1 | + | bounded | 0 | + | exclude_place_ids| 385252,1234515 | + | limit | 1000 | + | dedupe | 1 | + | dedupe | 0 | + | extratags | 1 | + | extratags | 0 | + | namedetails | 1 | + | namedetails | 0 | + + Scenario: Search with invalid output format + When sending search query "Berlin" + | format | + | fd$# | + Then a HTTP 400 is returned + + Scenario Outline: Simple Searches + When sending search query "" + Then the result is valid html + When sending html search query "" + Then the result is valid html + When sending xml search query "" + Then the result is valid xml + When sending json search query "" + Then the result is valid json + When sending jsonv2 search query "" + Then the result is valid json + + Examples: + | query | + | New York, New York | + | France | + | 12, Main Street, Houston | + | München | + | 東京都 | + | hotels in nantes | + | xywxkrf | + | gh; foo() | + | %#$@*&l;der#$! | + | 234 | + | 47.4,8.3 | + + Scenario: Empty XML search + When sending xml search query "xnznxvcx" + Then result header contains + | attr | value | + | querystring | xnznxvcx | + | polygon | false | + | more_url | .*format=xml.*q=xnznxvcx.* | + + Scenario: Empty XML search with special XML characters + When sending xml search query "xfdghn&zxn"xvbyxcssdex" + Then result header contains + | attr | value | + | querystring | xfdghn&zxn"xvbyxcssdex | + | polygon | false | + | more_url | .*format=xml.*q=xfdghn%26zxn%22xvbyx%3Cvxx%3Ecssdex.* | + + Scenario: Empty XML search with viewbox + When sending xml search query "xnznxvcx" + | viewbox | + | 12,45.13,77,33 | + Then result header contains + | attr | value | + | querystring | xnznxvcx | + | polygon | false | + | viewbox | 12,45.13,77,33 | + + Scenario: Empty XML search with viewboxlbrt + When sending xml search query "xnznxvcx" + | viewboxlbrt | + | 12,34.13,77,45 | + Then result header contains + | attr | value | + | querystring | xnznxvcx | + | polygon | false | + | viewbox | 12,45,77,34.13 | + + Scenario: Empty XML search with viewboxlbrt and viewbox + When sending xml search query "pub" + | viewbox | viewboxblrt | + | 12,45.13,77,33 | 1,2,3,4 | + Then result header contains + | attr | value | + | querystring | pub | + | polygon | false | + | viewbox | 12,45.13,77,33 | + + Scenario Outline: Empty XML search with polygon values + When sending xml search query "xnznxvcx" + | param | value | + | polygon | | + Then result header contains + | attr | value | + | polygon | | + + Examples: + | result | polyval | + | false | 0 | + | true | 1 | + | true | True | + | true | true | + | true | false | + | true | FALSE | + | true | yes | + | true | no | + | true | '; delete from foobar; select ' | + + Scenario: Empty XML search with exluded place ids + When sending xml search query "jghrleoxsbwjer" + | exclude_place_ids | + | 123,76,342565 | + Then result header contains + | attr | value | + | exclude_place_ids | 123,76,342565 | + + Scenario: Empty XML search with bad exluded place ids + When sending xml search query "jghrleoxsbwjer" + | exclude_place_ids | + | , | + Then result header has not attributes exclude_place_ids + + Scenario Outline: Wrapping of legal jsonp search requests + When sending json search query "Tokyo" + | param | value | + |json_callback | | + Then result header contains + | attr | value | + | json_func | | + + Examples: + | data | result | + | foo | foo | + | FOO | FOO | + | __world | __world | + | $me | \$me | + | m1[4] | m1\[4\] | + | d_r[$d] | d_r\[\$d\] | + + Scenario Outline: Wrapping of illegal jsonp search requests + When sending json search query "Tokyo" + | param | value | + |json_callback | | + Then a HTTP 400 is returned + + Examples: + | data | + | 1asd | + | bar(foo) | + | XXX['bad'] | + | foo; evil | + + Scenario: Ignore jsonp parameter for anything but json + When sending json search query "Malibu" + | json_callback | + | 234 | + Then a HTTP 400 is returned + When sending xml search query "Malibu" + | json_callback | + | 234 | + Then the result is valid xml + When sending html search query "Malibu" + | json_callback | + | 234 | + Then the result is valid html + + Scenario: Empty JSON search + When sending json search query "YHlERzzx" + Then exactly 0 results are returned + + Scenario: Empty JSONv2 search + When sending jsonv2 search query "Flubb XdfESSaZx" + Then exactly 0 results are returned + + Scenario: Search for non-existing coordinates + When sending json search query "-21.0,-33.0" + Then exactly 0 results are returned + From 81922fc057b5a89fb41d05d3a31dab629ff0558a Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Mon, 19 Dec 2016 21:38:42 +0100 Subject: [PATCH 20/29] more API search tests also move directory name back to api --- test/bdd/api/search/params.feature | 296 ++++++++++++++++++ test/bdd/api/search/queries.feature | 58 ++++ .../{website => api}/search/simple.feature | 0 test/bdd/steps/queries.py | 130 +++++++- 4 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 test/bdd/api/search/params.feature create mode 100644 test/bdd/api/search/queries.feature rename test/bdd/{website => api}/search/simple.feature (100%) diff --git a/test/bdd/api/search/params.feature b/test/bdd/api/search/params.feature new file mode 100644 index 00000000..65907379 --- /dev/null +++ b/test/bdd/api/search/params.feature @@ -0,0 +1,296 @@ +@APIDB +Feature: Search queries + Testing different queries and parameters + + Scenario: Simple XML search + When sending xml search query "Schaan" + Then result 0 has attributes place_id,osm_type,osm_id + And result 0 has attributes place_rank,boundingbox + And result 0 has attributes lat,lon,display_name + And result 0 has attributes class,type,importance,icon + And result 0 has not attributes address + And result 0 has bounding box in 46.5,47.5,9,10 + + Scenario: Simple JSON search + When sending json search query "Vaduz" + Then result 0 has attributes place_id,licence,icon,class,type + And result 0 has attributes osm_type,osm_id,boundingbox + And result 0 has attributes lat,lon,display_name,importance + And result 0 has not attributes address + And result 0 has bounding box in 46.5,47.5,9,10 + + Scenario: JSON search with addressdetails + When sending json search query "Montevideo" with address + Then address of result 0 is + | type | value | + | city | Montevideo | + | state | Montevideo | + | country | Uruguay | + | country_code | uy | + + Scenario: XML search with addressdetails + When sending xml search query "Aleg" with address + Then address of result 0 is + | type | value | + | city | Aleg | + | state | Brakna | + | country | Mauritania | + | country_code | mr | + + Scenario: coordinate search with addressdetails + When sending json search query "14.271104294939,107.69828796387" + Then results contain + | display_name | + | Plei Ya Rê, Kon Tum province, Vietnam | + + Scenario: Address details with unknown class types + When sending json search query "Hundeauslauf, Hamburg" with address + Then results contain + | ID | class | type | + | 0 | leisure | dog_park | + And result addresses contain + | ID | address29 | + | 0 | Hundeauslauf | + And address of result 0 has no types leisure,dog_park + + Scenario: Disabling deduplication + When sending json search query "Sievekingsallee, Hamburg" + Then there are no duplicates + When sending json search query "Sievekingsallee, Hamburg" + | dedupe | + | 0 | + Then there are duplicates + + Scenario: Search with bounded viewbox in right area + When sending json search query "restaurant" with address + | bounded | viewbox | + | 1 | 9.93027,53.61634,10.10073,53.54500 | + Then result addresses contain + | state | + | Hamburg | + + Scenario: Search with bounded viewboxlbrt in right area + When sending json search query "restaurant" with address + | bounded | viewboxlbrt | + | 1 | 9.93027,53.54500,10.10073,53.61634 | + Then result addresses contain + | state | + | Hamburg | + + Scenario: No POI search with unbounded viewbox + When sending json search query "restaurant" + | viewbox | + | 9.93027,53.61634,10.10073,53.54500 | + Then results contain + | display_name | + | ^[^,]*[Rr]estaurant.* | + + Scenario: bounded search remains within viewbox, even with no results + When sending json search query "restaurant" + | bounded | viewbox | + | 1 | 43.5403125,-5.6563282,43.54285,-5.662003 | + Then less than 1 result is returned + + Scenario: bounded search remains within viewbox with results + When sending json search query "restaurant" + | bounded | viewbox | + | 1 | 9.93027,53.61634,10.10073,53.54500 | + Then result has bounding box in 53.54500,53.61634,9.93027,10.10073 + + Scenario: Prefer results within viewbox + When sending json search query "25 de Mayo" with address + | accept-language | + | en | + Then result addresses contain + | ID | state | + | 0 | Salto | + When sending json search query "25 de Mayo" with address + | accept-language | viewbox | + | en | -56.35879,-34.18330,-56.31618,-34.20815 | + Then result addresses contain + | ID | state | + | 0 | Florida | + + Scenario: Overly large limit number for search results + When sending json search query "restaurant" + | limit | + | 1000 | + Then at most 50 results are returned + + Scenario: Limit number of search results + When sending json search query "restaurant" + | limit | + | 4 | + Then exactly 4 results are returned + + Scenario: Restrict to feature type country + When sending xml search query "Uruguay" + Then results contain + | ID | place_rank | + | 1 | 16 | + When sending xml search query "Uruguay" + | featureType | + | country | + Then results contain + | place_rank | + | 4 | + + Scenario: Restrict to feature type state + When sending xml search query "Dakota" + Then results contain + | place_rank | + | 12 | + When sending xml search query "Dakota" + | featureType | + | state | + Then results contain + | place_rank | + | 8 | + + Scenario: Restrict to feature type city + When sending xml search query "vaduz" + Then results contain + | ID | place_rank | + | 1 | 30 | + When sending xml search query "vaduz" + | featureType | + | city | + Then results contain + | place_rank | + | 16 | + + Scenario: Restrict to feature type settlement + When sending json search query "Burg" + Then results contain + | ID | class | + | 1 | amenity | + When sending json search query "Burg" + | featureType | + | settlement | + Then results contain + | class | type | + | boundary | administrative | + + Scenario Outline: Search with polygon threshold (json) + When sending json search query "switzerland" + | polygon_geojson | polygon_threshold | + | 1 |
| + Then at least 1 result is returned + And result 0 has attributes geojson + + Examples: + | th | + | -1 | + | 0.0 | + | 0.5 | + | 999 | + + Scenario Outline: Search with polygon threshold (xml) + When sending xml search query "switzerland" + | polygon_geojson | polygon_threshold | + | 1 | | + Then at least 1 result is returned + And result 0 has attributes geojson + + Examples: + | th | + | -1 | + | 0.0 | + | 0.5 | + | 999 | + + Scenario Outline: Search with invalid polygon threshold (xml) + When sending xml search query "switzerland" + | polygon_geojson | polygon_threshold | + | 1 | | + Then a HTTP 400 is returned + + Examples: + | th | + | x | + | ;; | + | 1m | + + Scenario Outline: Search with extratags + When sending search query "Hauptstr" + | extratags | + | 1 | + Then result has attributes extratags + + Examples: + | format | + | xml | + | json | + | jsonv2 | + + Scenario Outline: Search with namedetails + When sending search query "Hauptstr" + | namedetails | + | 1 | + Then result has attributes namedetails + + Examples: + | format | + | xml | + | json | + | jsonv2 | + + Scenario Outline: Search result with contains TEXT geometry + When sending search query "Highmore" + | polygon_text | + | 1 | + Then result has attributes + + Examples: + | format | response_attribute | + | xml | geotext | + | json | geotext | + | jsonv2 | geotext | + + Scenario Outline: Search result contains polygon-as-points geometry + When sending search query "Highmore" + | polygon | + | 1 | + Then result has attributes + + Examples: + | format | response_attribute | + | xml | polygonpoints | + | json | polygonpoints | + | jsonv2 | polygonpoints | + + Scenario Outline: Search result contains SVG geometry + When sending search query "Highmore" + | polygon_svg | + | 1 | + Then result has attributes + + Examples: + | format | response_attribute | + | xml | geosvg | + | json | svg | + | jsonv2 | svg | + + Scenario Outline: Search result contains KML geometry + When sending search query "Highmore" + | polygon_kml | + | 1 | + Then result has attributes + + Examples: + | format | response_attribute | + | xml | geokml | + | json | geokml | + | jsonv2 | geokml | + + Scenario Outline: Search result contains GEOJSON geometry + When sending search query "Highmore" + | polygon_geojson | + | 1 | + Then result has attributes + + Examples: + | format | response_attribute | + | xml | geojson | + | json | geojson | + | jsonv2 | geojson | diff --git a/test/bdd/api/search/queries.feature b/test/bdd/api/search/queries.feature new file mode 100644 index 00000000..78669c4f --- /dev/null +++ b/test/bdd/api/search/queries.feature @@ -0,0 +1,58 @@ +@APIDB +Feature: Search queries + Generic search result correctness + + Scenario: House number search for non-street address + When sending json search query "2 Steinwald, Austria" with address + | accept-language | + | en | + Then address of result 0 is + | type | value | + | house_number | 2 | + | hamlet | Steinwald | + | postcode | 6811 | + | country | Austria | + | country_code | at | + + Scenario: House number interpolation even + When sending json search query "Schellingstr 86, Hamburg" with address + | accept-language | + | de | + Then address of result 0 is + | type | value | + | house_number | 86 | + | road | Schellingstraße | + | suburb | Eilbek | + | postcode | 22089 | + | city_district | Wandsbek | + | state | Hamburg | + | country | Deutschland | + | country_code | de | + + Scenario: House number interpolation odd + When sending json search query "Schellingstr 73, Hamburg" with address + | accept-language | + | de | + Then address of result 0 is + | type | value | + | house_number | 73 | + | road | Schellingstraße | + | suburb | Eilbek | + | postcode | 22089 | + | city_district | Wandsbek | + | state | Hamburg | + | country | Deutschland | + | country_code | de | + + @Tiger + Scenario: TIGER house number + When sending json search query "323 22nd Street Southwest, Huron" + Then results contain + | osm_type | + | way | + + Scenario: Search with class-type feature + When sending jsonv2 search query "Hotel California" + Then results contain + | place_rank | + | 30 | diff --git a/test/bdd/website/search/simple.feature b/test/bdd/api/search/simple.feature similarity index 100% rename from test/bdd/website/search/simple.feature rename to test/bdd/api/search/simple.feature diff --git a/test/bdd/steps/queries.py b/test/bdd/steps/queries.py index c62b8a57..d0cda774 100644 --- a/test/bdd/steps/queries.py +++ b/test/bdd/steps/queries.py @@ -94,11 +94,29 @@ class SearchResponse(object): self.header = dict(et.attrib) - for child in et: assert_equal(child.tag, "place") self.result.append(dict(child.attrib)) + address = {} + for sub in child: + if sub.tag == 'extratags': + self.result[-1]['extratags'] = {} + for tag in sub: + self.result[-1]['extratags'][tag.attrib['key']] = tag.attrib['value'] + elif sub.tag == 'namedetails': + self.result[-1]['namedetails'] = {} + for tag in sub: + self.result[-1]['namedetails'][tag.attrib['desc']] = tag.text + elif sub.tag in ('geokml'): + self.result[-1][sub.tag] = True + else: + address[sub.tag] = sub.text + + if len(address) > 0: + self.result[-1]['address'] = address + + def match_row(self, row): if 'ID' in row.headings: todo = [int(row['ID'])] @@ -117,10 +135,18 @@ class SearchResponse(object): x, y = row[h].split(' ') assert_almost_equal(float(y), float(res['lat'])) assert_almost_equal(float(x), float(res['lon'])) + elif row[h].startswith("^"): + assert_in(h, res) + assert_is_not_none(re.fullmatch(row[h], res[h]), + "attribute '%s': expected: '%s', got '%s'" + % (h, row[h], res[h])) else: assert_in(h, res) assert_equal(str(res[h]), str(row[h])) + def property_list(self, prop): + return [ x[prop] for x in self.result ] + @when(u'searching for "(?P.*)"(?P with dups)?') def query_cmd(context, query, dups): @@ -147,13 +173,15 @@ def query_cmd(context, query, dups): context.response = SearchResponse(outp.decode('utf-8'), 'json') -@when(u'sending (?P\S+ )?search query "(?P.*)"') -def website_search_request(context, fmt, query): +@when(u'sending (?P\S+ )?search query "(?P.*)"(?P with address)?') +def website_search_request(context, fmt, query, addr): env = BASE_SERVER_ENV params = { 'q' : query } if fmt is not None: params['format'] = fmt.strip() + if addr is not None: + params['addressdetails'] = '1' if context.table: if context.table.headings[0] == 'param': for line in context.table: @@ -241,13 +269,101 @@ def step_impl(context): for line in context.table: context.response.match_row(line) -@then(u'result (?P\d+) has (?Pnot )?attributes (?P.*)') +@then(u'result (?P\d+ )?has (?Pnot )?attributes (?P.*)') def validate_attributes(context, lid, neg, attrs): - context.execute_steps("then at least %s result is returned" % lid) + if lid is None: + idx = range(len(context.response.result)) + context.execute_steps("then at least 1 result is returned") + else: + idx = [int(lid.strip())] + context.execute_steps("then more than %sresults are returned" % lid) + + for i in idx: + for attr in attrs.split(','): + if neg: + assert_not_in(attr, context.response.result[i]) + else: + assert_in(attr, context.response.result[i]) + +@then(u'result addresses contain') +def step_impl(context): + context.execute_steps("then at least 1 result is returned") + + if 'ID' not in context.table.headings: + addr_parts = context.response.property_list('address') + + for line in context.table: + if 'ID' in context.table.headings: + addr_parts = [dict(context.response.result[int(line['ID'])]['address'])] + + for h in context.table.headings: + if h != 'ID': + for p in addr_parts: + assert_in(h, p) + assert_equal(p[h], line[h], "Bad address value for %s" % h) + +@then(u'address of result (?P\d+) has(?P no)? types (?P.*)') +def check_address(context, lid, neg, attrs): + context.execute_steps("then more than %s results are returned" % lid) + + addr_parts = context.response.result[int(lid)]['address'] for attr in attrs.split(','): if neg: - assert_not_in(attr, context.response.result[int(lid)]) + assert_not_in(attr, addr_parts) else: - assert_in(attr, context.response.result[int(lid)]) + assert_in(attr, addr_parts) +@then(u'address of result (?P\d+) is') +def check_address(context, lid): + context.execute_steps("then more than %s results are returned" % lid) + + addr_parts = dict(context.response.result[int(lid)]['address']) + + for line in context.table: + assert_in(line['type'], addr_parts) + assert_equal(addr_parts[line['type']], line['value'], + "Bad address value for %s" % line['type']) + del addr_parts[line['type']] + + eq_(0, len(addr_parts), "Additional address parts found: %s" % str(addr_parts)) + +@then(u'result (?P\d+ )?has bounding box in (?P[\d,.-]+)') +def step_impl(context, lid, coords): + if lid is None: + context.execute_steps("then at least 1 result is returned") + bboxes = context.response.property_list('boundingbox') + else: + context.execute_steps("then more than %sresults are returned" % lid) + bboxes = [ context.response.result[int(lid)]['boundingbox']] + + coord = [ float(x) for x in coords.split(',') ] + + for bbox in bboxes: + if isinstance(bbox, str): + bbox = bbox.split(',') + bbox = [ float(x) for x in bbox ] + + assert_greater_equal(bbox[0], coord[0]) + assert_less_equal(bbox[1], coord[1]) + assert_greater_equal(bbox[2], coord[2]) + assert_less_equal(bbox[3], coord[3]) + +@then(u'there are(?P no)? duplicates') +def check_for_duplicates(context, neg): + context.execute_steps("then at least 1 result is returned") + + resarr = set() + has_dupe = False + + for res in context.response.result: + dup = (res['osm_type'], res['class'], res['type'], res['display_name']) + if dup in resarr: + has_dupe = True + break + resarr.add(dup) + + if neg: + assert not has_dupe, "Found duplicate for %s" % (dup, ) + else: + assert has_dupe, "No duplicates found" From 201f618cc727a4351e4c93abc22bc8e061a00167 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Wed, 21 Dec 2016 20:28:27 +0100 Subject: [PATCH 21/29] finish search API tests --- test/bdd/api/search/queries.feature | 9 ++++++ test/bdd/api/search/structured.feature | 38 ++++++++++++++++++++++++++ test/bdd/steps/queries.py | 4 ++- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 test/bdd/api/search/structured.feature diff --git a/test/bdd/api/search/queries.feature b/test/bdd/api/search/queries.feature index 78669c4f..0074e334 100644 --- a/test/bdd/api/search/queries.feature +++ b/test/bdd/api/search/queries.feature @@ -56,3 +56,12 @@ Feature: Search queries Then results contain | place_rank | | 30 | + + # https://trac.openstreetmap.org/ticket/5094 + Scenario: housenumbers are ordered by complete match first + When sending json search query "6395 geminis, montevideo" with address + Then result addresses contain + | ID | house_number | + | 0 | 6395 | + | 1 | 6395 BIS | + diff --git a/test/bdd/api/search/structured.feature b/test/bdd/api/search/structured.feature new file mode 100644 index 00000000..c93603d6 --- /dev/null +++ b/test/bdd/api/search/structured.feature @@ -0,0 +1,38 @@ +@APIDB +Feature: Structured search queries + Testing correctness of results with + structured queries + + Scenario: Country only + When sending json search query "" with address + | country | + | Liechtenstein | + Then address of result 0 is + | type | value | + | country | Liechtenstein | + | country_code | li | + + Scenario: Postcode only + When sending json search query "" with address + | postalcode | + | 22547 | + Then results contain + | type | + | postcode | + And result addresses contain + | postcode | + | 22547 | + + Scenario: Street, postcode and country + When sending xml search query "" with address + | street | postalcode | country | + | Old Palace Road | GU2 7UP | United Kingdom | + Then result header contains + | attr | value | + | querystring | Old Palace Road, GU2 7UP, United Kingdom | + + Scenario: gihub #176 + When sending json search query "" with address + | city | + | Mercedes | + Then at least 1 result is returned diff --git a/test/bdd/steps/queries.py b/test/bdd/steps/queries.py index d0cda774..b02a6661 100644 --- a/test/bdd/steps/queries.py +++ b/test/bdd/steps/queries.py @@ -177,7 +177,9 @@ def query_cmd(context, query, dups): def website_search_request(context, fmt, query, addr): env = BASE_SERVER_ENV - params = { 'q' : query } + params = {} + if query: + params['q'] = query if fmt is not None: params['format'] = fmt.strip() if addr is not None: From 635ce30db5a5086a5881fdc766f0dc450677bf62 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Wed, 21 Dec 2016 22:47:47 +0100 Subject: [PATCH 22/29] add simple reverse API tests --- test/bdd/api/reverse/simple.feature | 130 ++++++++++++++++++++++++++++ test/bdd/steps/queries.py | 125 ++++++++++++++++++++++---- 2 files changed, 239 insertions(+), 16 deletions(-) create mode 100644 test/bdd/api/reverse/simple.feature diff --git a/test/bdd/api/reverse/simple.feature b/test/bdd/api/reverse/simple.feature new file mode 100644 index 00000000..b14d9e86 --- /dev/null +++ b/test/bdd/api/reverse/simple.feature @@ -0,0 +1,130 @@ +@APIDB +Feature: Simple Reverse Tests + Simple tests for internal server errors and response format. + + Scenario Outline: Simple reverse-geocoding + When sending reverse coordinates , + Then the result is valid xml + When sending xml reverse coordinates , + Then the result is valid xml + When sending json reverse coordinates , + Then the result is valid json + When sending jsonv2 reverse coordinates , + Then the result is valid json + When sending html reverse coordinates , + Then the result is valid html + + Examples: + | lat | lon | + | 0.0 | 0.0 | + | -34.830 | -56.105 | + | 45.174 | -103.072 | + | 21.156 | -12.2744 | + + Scenario Outline: Testing different parameters + When sending reverse coordinates 53.603,10.041 + | param | value | + | | | + Then the result is valid xml + When sending html reverse coordinates 53.603,10.041 + | param | value | + | | | + Then the result is valid html + When sending xml reverse coordinates 53.603,10.041 + | param | value | + | | | + Then the result is valid xml + When sending json reverse coordinates 53.603,10.041 + | param | value | + | | | + Then the result is valid json + When sending jsonv2 reverse coordinates 53.603,10.041 + | param | value | + | | | + Then the result is valid json + + Examples: + | parameter | value | + | polygon | 1 | + | polygon | 0 | + | polygon_text | 1 | + | polygon_text | 0 | + | polygon_kml | 1 | + | polygon_kml | 0 | + | polygon_geojson | 1 | + | polygon_geojson | 0 | + | polygon_svg | 1 | + | polygon_svg | 0 | + + Scenario Outline: Wrapping of legal jsonp requests + When sending reverse coordinates 67.3245,0.456 + | json_callback | + | foo | + Then the result is valid json + + Examples: + | format | + | json | + | jsonv2 | + + Scenario Outline: Reverse-geocoding without address + When sending reverse coordinates 53.603,10.041 + | addressdetails | + | 0 | + Then exactly 1 result is returned + + Examples: + | format | + | json | + | jsonv2 | + | html | + | xml | + + Scenario Outline: Reverse-geocoding with zoom + When sending reverse coordinates 53.603,10.041 + | zoom | + | 10 | + Then exactly 1 result is returned + + Examples: + | format | + | json | + | jsonv2 | + | html | + | xml | + + Scenario: Missing lon parameter + When sending reverse coordinates 52.52, + Then a HTTP 400 is returned + + Scenario: Missing lat parameter + When sending reverse coordinates ,52.52 + Then a HTTP 400 is returned + + Scenario: Missing osm_id parameter + When sending reverse coordinates , + | osm_type | + | N | + Then a HTTP 400 is returned + + Scenario: Missing osm_type parameter + When sending reverse coordinates , + | osm_id | + | 3498564 | + Then a HTTP 400 is returned + + Scenario Outline: Bad format for lat or lon + When sending reverse coordinates , + | lat | lon | + | | | + Then a HTTP 400 is returned + + Examples: + | lat | lon | + | 48.9660 | 8,4482 | + | 48,9660 | 8.4482 | + | 48,9660 | 8,4482 | + | 48.966.0 | 8.4482 | + | 48.966 | 8.448.2 | + | Nan | 8.448 | + | 48.966 | Nan | diff --git a/test/bdd/steps/queries.py b/test/bdd/steps/queries.py index b02a6661..81dc0ccd 100644 --- a/test/bdd/steps/queries.py +++ b/test/bdd/steps/queries.py @@ -148,6 +148,71 @@ class SearchResponse(object): return [ x[prop] for x in self.result ] +class ReverseResponse(object): + + def __init__(self, page, fmt='json', errorcode=200): + self.page = page + self.format = fmt + self.errorcode = errorcode + self.result = [] + self.header = dict() + + if errorcode == 200: + getattr(self, 'parse_' + fmt)() + + def parse_html(self): + content, errors = tidy_document(self.page, + options={'char-encoding' : 'utf8'}) + #eq_(len(errors), 0 , "Errors found in HTML document:\n%s" % errors) + + b = content.find('nominatim_results =') + e = content.find('') + content = content[b:e] + b = content.find('[') + e = content.rfind(']') + + self.result = json.JSONDecoder(object_pairs_hook=OrderedDict).decode(content[b:e+1]) + + def parse_json(self): + m = re.fullmatch(r'([\w$][^(]*)\((.*)\)', self.page) + if m is None: + code = self.page + else: + code = m.group(2) + self.header['json_func'] = m.group(1) + self.result = [json.JSONDecoder(object_pairs_hook=OrderedDict).decode(code)] + + def parse_xml(self): + et = ET.fromstring(self.page) + + self.header = dict(et.attrib) + self.result = [] + + for child in et: + if child.tag == 'result': + eq_(0, len(self.result), "More than one result in reverse result") + self.result.append(dict(child.attrib)) + elif child.tag == 'addressparts': + address = {} + for sub in child: + address[sub.tag] = sub.text + self.result[0]['address'] = address + elif child.tag == 'extratags': + self.result[0]['extratags'] = {} + for tag in child: + self.result[0]['extratags'][tag.attrib['key']] = tag.attrib['value'] + elif child.tag == 'namedetails': + self.result[0]['namedetails'] = {} + for tag in child: + self.result[0]['namedetails'][tag.attrib['desc']] = tag.text + elif child.tag in ('geokml'): + self.result[0][child.tag] = True + else: + assert child.tag == 'error', \ + "Unknown XML tag %s on page: %s" % (child.tag, self.page) + + + @when(u'searching for "(?P.*)"(?P with dups)?') def query_cmd(context, query, dups): """ Query directly via PHP script. @@ -172,18 +237,9 @@ def query_cmd(context, query, dups): context.response = SearchResponse(outp.decode('utf-8'), 'json') - -@when(u'sending (?P\S+ )?search query "(?P.*)"(?P with address)?') -def website_search_request(context, fmt, query, addr): - env = BASE_SERVER_ENV - - params = {} - if query: - params['q'] = query +def send_api_query(endpoint, params, fmt, context): if fmt is not None: params['format'] = fmt.strip() - if addr is not None: - params['addressdetails'] = '1' if context.table: if context.table.headings[0] == 'param': for line in context.table: @@ -191,15 +247,18 @@ def website_search_request(context, fmt, query, addr): else: for h in context.table.headings: params[h] = context.table[0][h] + + env = BASE_SERVER_ENV env['QUERY_STRING'] = urlencode(params) - env['REQUEST_URI'] = '/search.php?' + env['QUERY_STRING'] - env['SCRIPT_NAME'] = '/search.php' + env['SCRIPT_NAME'] = '/%s.php' % endpoint + env['REQUEST_URI'] = '%s?%s' % (env['SCRIPT_NAME'], env['QUERY_STRING']) env['CONTEXT_DOCUMENT_ROOT'] = os.path.join(context.nominatim.build_dir, 'website') - env['SCRIPT_FILENAME'] = os.path.join(context.nominatim.build_dir, 'website', 'search.php') + env['SCRIPT_FILENAME'] = os.path.join(env['CONTEXT_DOCUMENT_ROOT'], + '%s.php' % endpoint) env['NOMINATIM_SETTINGS'] = context.nominatim.local_settings_file - cmd = [ '/usr/bin/php-cgi', env['SCRIPT_FILENAME']] + cmd = ['/usr/bin/php-cgi', env['SCRIPT_FILENAME']] for k,v in params.items(): cmd.append("%s=%s" % (k, v)) @@ -221,7 +280,20 @@ def website_search_request(context, fmt, query, addr): status = 200 content_start = outp.find('\r\n\r\n') - assert_less(11, content_start) + + return outp[content_start + 4:], status + + +@when(u'sending (?P\S+ )?search query "(?P.*)"(?P with address)?') +def website_search_request(context, fmt, query, addr): + + params = {} + if query: + params['q'] = query + if addr is not None: + params['addressdetails'] = '1' + + outp, status = send_api_query('search', params, fmt, context) if fmt is None: outfmt = 'html' @@ -230,7 +302,27 @@ def website_search_request(context, fmt, query, addr): else: outfmt = fmt.strip() - context.response = SearchResponse(outp[content_start + 4:], outfmt, status) + context.response = SearchResponse(outp, outfmt, status) + +@when(u'sending (?P\S+ )?reverse coordinates (?P[0-9.-]+)?,(?P[0-9.-]+)?') +def website_reverse_request(context, fmt, lat, lon): + params = {} + if lat is not None: + params['lat'] = lat + if lon is not None: + params['lon'] = lon + + outp, status = send_api_query('reverse', params, fmt, context) + + if fmt is None: + outfmt = 'xml' + elif fmt == 'jsonv2 ': + outfmt = 'json' + else: + outfmt = fmt.strip() + + context.response = ReverseResponse(outp, outfmt, status) + @step(u'(?Pless than|more than|exactly|at least|at most) (?P\d+) results? (?:is|are) returned') @@ -246,6 +338,7 @@ def check_http_return_status(context, status): @then(u'the result is valid (?P\w+)') def step_impl(context, fmt): + context.execute_steps("Then a HTTP 200 is returned") eq_(context.response.format, fmt) @then(u'result header contains') From 3a787df9347454396c963353e3ae89bbc3501fd1 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Thu, 22 Dec 2016 22:28:23 +0100 Subject: [PATCH 23/29] add remaining reverse tests --- test/bdd/api/reverse/params.feature | 102 +++++++++++++++++++++++ test/bdd/api/reverse/queries.feature | 25 ++++++ test/bdd/api/reverse/simple.feature | 12 +-- test/bdd/db/update/interpolation.feature | 25 ++++++ test/bdd/steps/queries.py | 67 +++++++-------- 5 files changed, 192 insertions(+), 39 deletions(-) create mode 100644 test/bdd/api/reverse/params.feature create mode 100644 test/bdd/api/reverse/queries.feature diff --git a/test/bdd/api/reverse/params.feature b/test/bdd/api/reverse/params.feature new file mode 100644 index 00000000..0d35cdc7 --- /dev/null +++ b/test/bdd/api/reverse/params.feature @@ -0,0 +1,102 @@ +@APIDB +Feature: Parameters for Reverse API + Testing diferent parameter options for reverse API. + + Scenario Outline: Reverse-geocoding without address + When sending reverse coordinates 53.603,10.041 + | addressdetails | + | 0 | + Then exactly 1 result is returned + And result has not attributes address + + Examples: + | format | + | json | + | jsonv2 | + | xml | + + Scenario Outline: Reverse Geocoding with extratags + When sending reverse coordinates 10.776234290950017,106.70425325632095 + | extratags | + | 1 | + Then result 0 has attributes extratags + + Examples: + | format | + | xml | + | json | + | jsonv2 | + + Scenario Outline: Reverse Geocoding with namedetails + When sending reverse coordinates 10.776455623137625,106.70175343751907 + | namedetails | + | 1 | + Then result 0 has attributes namedetails + + Examples: + | format | + | xml | + | json | + | jsonv2 | + + Scenario Outline: Reverse Geocoding contains TEXT geometry + When sending reverse coordinates 47.165989816710066,9.515774846076965 + | polygon_text | + | 1 | + Then result 0 has attributes + + Examples: + | format | response_attribute | + | xml | geotext | + | json | geotext | + | jsonv2 | geotext | + + Scenario Outline: Reverse Geocoding contains polygon-as-points geometry + When sending reverse coordinates 47.165989816710066,9.515774846076965 + | polygon | + | 1 | + Then result 0 has not attributes + + Examples: + | format | response_attribute | + | xml | polygonpoints | + | json | polygonpoints | + | jsonv2 | polygonpoints | + + Scenario Outline: Reverse Geocoding contains SVG geometry + When sending reverse coordinates 47.165989816710066,9.515774846076965 + | polygon_svg | + | 1 | + Then result 0 has attributes + + Examples: + | format | response_attribute | + | xml | geosvg | + | json | svg | + | jsonv2 | svg | + + Scenario Outline: Reverse Geocoding contains KML geometry + When sending reverse coordinates 47.165989816710066,9.515774846076965 + | polygon_kml | + | 1 | + Then result 0 has attributes + + Examples: + | format | response_attribute | + | xml | geokml | + | json | geokml | + | jsonv2 | geokml | + + Scenario Outline: Reverse Geocoding contains GEOJSON geometry + When sending reverse coordinates 47.165989816710066,9.515774846076965 + | polygon_geojson | + | 1 | + Then result 0 has attributes + + Examples: + | format | response_attribute | + | xml | geojson | + | json | geojson | + | jsonv2 | geojson | + + diff --git a/test/bdd/api/reverse/queries.feature b/test/bdd/api/reverse/queries.feature new file mode 100644 index 00000000..e1d089b9 --- /dev/null +++ b/test/bdd/api/reverse/queries.feature @@ -0,0 +1,25 @@ +@APIDB +Feature: Reverse geocoding + Testing the reverse function + + @Tiger + Scenario: TIGER house number + When sending jsonv2 reverse coordinates 45.3345,-97.5214 + Then results contain + | osm_type | category | type | + | way | place | house | + And result addresses contain + | house_number | road | postcode | country_code | + | 906 | West 1st Street | 57274 | us | + + @Tiger + Scenario: No TIGER house number for zoom < 18 + When sending jsonv2 reverse coordinates 45.3345,-97.5214 + | zoom | + | 17 | + Then results contain + | osm_type | category | + | way | highway | + And result addresses contain + | road | postcode | country_code | + | West 1st Street | 57274 | us | diff --git a/test/bdd/api/reverse/simple.feature b/test/bdd/api/reverse/simple.feature index b14d9e86..2b484736 100644 --- a/test/bdd/api/reverse/simple.feature +++ b/test/bdd/api/reverse/simple.feature @@ -67,17 +67,17 @@ Feature: Simple Reverse Tests | json | | jsonv2 | - Scenario Outline: Reverse-geocoding without address - When sending reverse coordinates 53.603,10.041 - | addressdetails | - | 0 | - Then exactly 1 result is returned + @wip + Scenario Outline: Boundingbox is returned + When sending reverse coordinates 14.62,108.1 + | zoom | + | 4 | + Then result has bounding box in 9,20,102,113 Examples: | format | | json | | jsonv2 | - | html | | xml | Scenario Outline: Reverse-geocoding with zoom diff --git a/test/bdd/db/update/interpolation.feature b/test/bdd/db/update/interpolation.feature index a9e56cce..7dd5bdc0 100644 --- a/test/bdd/db/update/interpolation.feature +++ b/test/bdd/db/update/interpolation.feature @@ -2,6 +2,31 @@ Feature: Update of address interpolations Test the interpolated address are updated correctly + @wip + Scenario: new interpolation added to existing street + Given the scene parallel-road + And the places + | osm | class | type | name | geometry | + | W2 | highway | unclassified | Sun Way | :w-north | + | W3 | highway | unclassified | Cloud Street | :w-south | + And the ways + | id | nodes | + | 10 | 1,100,101,102,2 | + When importing + Then W10 expands to no interpolation + When updating places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | :n-middle-w | + | N2 | place | house | 6 | :n-middle-e | + | W10 | place | houses | even | :w-middle | + Then placex contains + | object | parent_place_id | + | N1 | W2 | + | N2 | W2 | + And W10 expands to interpolation + | parent_place_id | start | end | + | W2 | 2 | 6 | + Scenario: addr:street added to interpolation Given the scene parallel-road And the places diff --git a/test/bdd/steps/queries.py b/test/bdd/steps/queries.py index 81dc0ccd..175a85ae 100644 --- a/test/bdd/steps/queries.py +++ b/test/bdd/steps/queries.py @@ -54,8 +54,40 @@ def compare(operator, op1, op2): else: raise Exception("unknown operator '%s'" % operator) +class GenericResponse(object): -class SearchResponse(object): + def match_row(self, row): + if 'ID' in row.headings: + todo = [int(row['ID'])] + else: + todo = range(len(self.result)) + + for i in todo: + res = self.result[i] + for h in row.headings: + if h == 'ID': + pass + elif h == 'osm': + assert_equal(res['osm_type'], row[h][0]) + assert_equal(res['osm_id'], row[h][1:]) + elif h == 'centroid': + x, y = row[h].split(' ') + assert_almost_equal(float(y), float(res['lat'])) + assert_almost_equal(float(x), float(res['lon'])) + elif row[h].startswith("^"): + assert_in(h, res) + assert_is_not_none(re.fullmatch(row[h], res[h]), + "attribute '%s': expected: '%s', got '%s'" + % (h, row[h], res[h])) + else: + assert_in(h, res) + assert_equal(str(res[h]), str(row[h])) + + def property_list(self, prop): + return [ x[prop] for x in self.result ] + + +class SearchResponse(GenericResponse): def __init__(self, page, fmt='json', errorcode=200): self.page = page @@ -117,38 +149,8 @@ class SearchResponse(object): self.result[-1]['address'] = address - def match_row(self, row): - if 'ID' in row.headings: - todo = [int(row['ID'])] - else: - todo = range(len(self.result)) - for i in todo: - res = self.result[i] - for h in row.headings: - if h == 'ID': - pass - elif h == 'osm': - assert_equal(res['osm_type'], row[h][0]) - assert_equal(res['osm_id'], row[h][1:]) - elif h == 'centroid': - x, y = row[h].split(' ') - assert_almost_equal(float(y), float(res['lat'])) - assert_almost_equal(float(x), float(res['lon'])) - elif row[h].startswith("^"): - assert_in(h, res) - assert_is_not_none(re.fullmatch(row[h], res[h]), - "attribute '%s': expected: '%s', got '%s'" - % (h, row[h], res[h])) - else: - assert_in(h, res) - assert_equal(str(res[h]), str(row[h])) - - def property_list(self, prop): - return [ x[prop] for x in self.result ] - - -class ReverseResponse(object): +class ReverseResponse(GenericResponse): def __init__(self, page, fmt='json', errorcode=200): self.page = page @@ -212,7 +214,6 @@ class ReverseResponse(object): "Unknown XML tag %s on page: %s" % (child.tag, self.page) - @when(u'searching for "(?P.*)"(?P with dups)?') def query_cmd(context, query, dups): """ Query directly via PHP script. From b2c1d086b5f60f6163e9994fc64662ecec9da045 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Wed, 28 Dec 2016 22:57:52 +0100 Subject: [PATCH 24/29] add api tests for language, details and lookup --- test/bdd/api/details/simple.feature | 14 ++++++ test/bdd/api/lookup/simple.feature | 17 ++++++++ test/bdd/api/reverse/language.feature | 36 ++++++++++++++++ test/bdd/api/search/language.feature | 62 +++++++++++++++++++++++++++ test/bdd/api/search/params.feature | 4 ++ test/bdd/steps/queries.py | 55 ++++++++++++++++++++++-- 6 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 test/bdd/api/details/simple.feature create mode 100644 test/bdd/api/lookup/simple.feature create mode 100644 test/bdd/api/reverse/language.feature create mode 100644 test/bdd/api/search/language.feature diff --git a/test/bdd/api/details/simple.feature b/test/bdd/api/details/simple.feature new file mode 100644 index 00000000..638e89ca --- /dev/null +++ b/test/bdd/api/details/simple.feature @@ -0,0 +1,14 @@ +@APIDB +Feature: Object details + Check details page for correctness + + Scenario Outline: Details via OSM id + When sending details query for + Then the result is valid html + + Examples: + | object | + | 492887 | + | N4267356889 | + | W230804120 | + | R123924 | diff --git a/test/bdd/api/lookup/simple.feature b/test/bdd/api/lookup/simple.feature new file mode 100644 index 00000000..5ec185c5 --- /dev/null +++ b/test/bdd/api/lookup/simple.feature @@ -0,0 +1,17 @@ +@APIDB +Feature: Places by osm_type and osm_id Tests + Simple tests for internal server errors and response format. + + Scenario Outline: address lookup for existing node, way, relation + When sending lookup query for N3284625766,W6065798,,R123924,X99,N0 + Then the result is valid + And exactly 3 results are returned + + Examples: + | format | + | xml | + | json | + + Scenario: address lookup for non-existing or invalid node, way, relation + When sending xml lookup query for X99,,N0,nN158845944,ABC,,W9 + Then exactly 0 results are returned diff --git a/test/bdd/api/reverse/language.feature b/test/bdd/api/reverse/language.feature new file mode 100644 index 00000000..9bde2d4e --- /dev/null +++ b/test/bdd/api/reverse/language.feature @@ -0,0 +1,36 @@ +@APIDB +Feature: Localization of reverse search results + + Scenario: default language + When sending json reverse coordinates 18.1147,-15.95 + Then result addresses contain + | ID | country | + | 0 | Mauritanie موريتانيا | + + Scenario: accept-language parameter + When sending json reverse coordinates 18.1147,-15.95 + | accept-language | + | en,fr | + Then result addresses contain + | ID | country | + | 0 | Mauritania | + + Scenario: HTTP accept language header + Given the HTTP header + | accept-language | + | fr-ca,fr;q=0.8,en-ca;q=0.5,en;q=0.3 | + When sending json reverse coordinates 18.1147,-15.95 + Then result addresses contain + | ID | country | + | 0 | Mauritanie | + + Scenario: accept-language parameter and HTTP header + Given the HTTP header + | accept-language | + | fr-ca,fr;q=0.8,en-ca;q=0.5,en;q=0.3 | + When sending json reverse coordinates 18.1147,-15.95 + | accept-language | + | en | + Then result addresses contain + | ID | country | + | 0 | Mauritania | diff --git a/test/bdd/api/search/language.feature b/test/bdd/api/search/language.feature new file mode 100644 index 00000000..d077e4da --- /dev/null +++ b/test/bdd/api/search/language.feature @@ -0,0 +1,62 @@ +@APIDB +Feature: Localization of search results + + Scenario: default language + When sending json search query "Vietnam" + Then results contain + | ID | display_name | + | 0 | Việt Nam | + + Scenario: accept-language first + When sending json search query "Mauretanien" + | accept-language | + | en,de | + Then results contain + | ID | display_name | + | 0 | Mauritania | + + Scenario: accept-language missing + When sending json search query "Mauretanien" + | accept-language | + | xx,fr,en,de | + Then results contain + | ID | display_name | + | 0 | Mauritanie | + + Scenario: http accept language header first + Given the HTTP header + | accept-language | + | fr-ca,fr;q=0.8,en-ca;q=0.5,en;q=0.3 | + When sending json search query "Mauretanien" + Then results contain + | ID | display_name | + | 0 | Mauritanie | + + Scenario: http accept language header and accept-language + Given the HTTP header + | accept-language | + | fr-ca,fr;q=0.8,en-ca;q=0.5,en;q=0.3 | + When sending json search query "Mauretanien" + | accept-language | + | de,en | + Then results contain + | ID | display_name | + | 0 | Mauretanien | + + Scenario: http accept language header fallback + Given the HTTP header + | accept-language | + | fr-ca,en-ca;q=0.5 | + When sending json search query "Mauretanien" + Then results contain + | ID | display_name | + | 0 | Mauritanie | + + Scenario: http accept language header fallback (upper case) + Given the HTTP header + | accept-language | + | fr-FR;q=0.8,en-ca;q=0.5 | + When sending json search query "Mauretanie" + Then results contain + | ID | display_name | + | 0 | Mauritanie | diff --git a/test/bdd/api/search/params.feature b/test/bdd/api/search/params.feature index 65907379..1fa16383 100644 --- a/test/bdd/api/search/params.feature +++ b/test/bdd/api/search/params.feature @@ -30,6 +30,8 @@ Feature: Search queries Scenario: XML search with addressdetails When sending xml search query "Aleg" with address + | accept-language | + | en | Then address of result 0 is | type | value | | city | Aleg | @@ -39,6 +41,8 @@ Feature: Search queries Scenario: coordinate search with addressdetails When sending json search query "14.271104294939,107.69828796387" + | accept-language | + | en | Then results contain | display_name | | Plei Ya Rê, Kon Tum province, Vietnam | diff --git a/test/bdd/steps/queries.py b/test/bdd/steps/queries.py index 175a85ae..7d3dec69 100644 --- a/test/bdd/steps/queries.py +++ b/test/bdd/steps/queries.py @@ -19,7 +19,6 @@ BASE_SERVER_ENV = { 'HTTP_HOST' : 'localhost', 'HTTP_USER_AGENT' : 'Mozilla/5.0 (X11; Linux x86_64; rv:51.0) Gecko/20100101 Firefox/51.0', 'HTTP_ACCEPT' : 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'HTTP_ACCEPT_LANGUAGE' : 'en,de;q=0.5', 'HTTP_ACCEPT_ENCODING' : 'gzip, deflate', 'HTTP_CONNECTION' : 'keep-alive', 'SERVER_SIGNATURE' : '
Nominatim BDD Tests
', @@ -149,7 +148,6 @@ class SearchResponse(GenericResponse): self.result[-1]['address'] = address - class ReverseResponse(GenericResponse): def __init__(self, page, fmt='json', errorcode=200): @@ -214,6 +212,23 @@ class ReverseResponse(GenericResponse): "Unknown XML tag %s on page: %s" % (child.tag, self.page) +class DetailsResponse(GenericResponse): + + def __init__(self, page, fmt='json', errorcode=200): + self.page = page + self.format = fmt + self.errorcode = errorcode + self.result = [] + self.header = dict() + + if errorcode == 200: + getattr(self, 'parse_' + fmt)() + + def parse_html(self): + content, errors = tidy_document(self.page, + options={'char-encoding' : 'utf8'}) + self.result = {} + @when(u'searching for "(?P.*)"(?P with dups)?') def query_cmd(context, query, dups): """ Query directly via PHP script. @@ -259,6 +274,9 @@ def send_api_query(endpoint, params, fmt, context): '%s.php' % endpoint) env['NOMINATIM_SETTINGS'] = context.nominatim.local_settings_file + if hasattr(context, 'http_headers'): + env.update(context.http_headers) + cmd = ['/usr/bin/php-cgi', env['SCRIPT_FILENAME']] for k,v in params.items(): cmd.append("%s=%s" % (k, v)) @@ -284,10 +302,18 @@ def send_api_query(endpoint, params, fmt, context): return outp[content_start + 4:], status +@given(u'the HTTP header') +def add_http_header(context): + if not hasattr(context, 'http_headers'): + context.http_headers = {} + + for h in context.table.headings: + envvar = 'HTTP_' + h.upper().replace('-', '_') + context.http_headers[envvar] = context.table[0][h] + @when(u'sending (?P\S+ )?search query "(?P.*)"(?P with address)?') def website_search_request(context, fmt, query, addr): - params = {} if query: params['q'] = query @@ -324,6 +350,29 @@ def website_reverse_request(context, fmt, lat, lon): context.response = ReverseResponse(outp, outfmt, status) +@when(u'sending (?P\S+ )?details query for (?P.*)') +def website_details_request(context, fmt, query): + params = {} + if query[0] in 'NWR': + params['osmtype'] = query[0] + params['osmid'] = query[1:] + else: + params['place_id'] = query + outp, status = send_api_query('details', params, fmt, context) + + context.response = DetailsResponse(outp, 'html', status) + +@when(u'sending (?P\S+ )?lookup query for (?P.*)') +def website_lookup_request(context, fmt, query): + params = { 'osm_ids' : query } + outp, status = send_api_query('lookup', params, fmt, context) + + if fmt == 'json ': + outfmt = 'json' + else: + outfmt = 'xml' + + context.response = SearchResponse(outp, outfmt, status) @step(u'(?Pless than|more than|exactly|at least|at most) (?P\d+) results? (?:is|are) returned') From ccaea09a650af18daf556a6cb3b2997c6523a18d Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Wed, 28 Dec 2016 23:07:20 +0100 Subject: [PATCH 25/29] move scenes directory to new test directory --- {tests => test}/scenes/bin/Makefile | 0 {tests => test}/scenes/bin/make_scenes.sh | 0 {tests => test}/scenes/bin/osm2wkt.cc | 0 {tests => test}/scenes/data/building-on-street-corner.wkt | 0 {tests => test}/scenes/data/building-with-parallel-streets.wkt | 0 {tests => test}/scenes/data/country.sql | 0 {tests => test}/scenes/data/country.wkt | 0 {tests => test}/scenes/data/parallel-road.wkt | 0 {tests => test}/scenes/data/points-on-roads.wkt | 0 {tests => test}/scenes/data/poly-area.wkt | 0 {tests => test}/scenes/data/poly-areas.osm | 0 {tests => test}/scenes/data/road-with-alley.wkt | 0 {tests => test}/scenes/data/roads-with-pois.wkt | 0 {tests => test}/scenes/data/roads.osm | 0 {tests => test}/scenes/data/split-road.wkt | 0 {tests => test}/scenes/data/way-area-with-center.wkt | 0 16 files changed, 0 insertions(+), 0 deletions(-) rename {tests => test}/scenes/bin/Makefile (100%) rename {tests => test}/scenes/bin/make_scenes.sh (100%) rename {tests => test}/scenes/bin/osm2wkt.cc (100%) rename {tests => test}/scenes/data/building-on-street-corner.wkt (100%) rename {tests => test}/scenes/data/building-with-parallel-streets.wkt (100%) rename {tests => test}/scenes/data/country.sql (100%) rename {tests => test}/scenes/data/country.wkt (100%) rename {tests => test}/scenes/data/parallel-road.wkt (100%) rename {tests => test}/scenes/data/points-on-roads.wkt (100%) rename {tests => test}/scenes/data/poly-area.wkt (100%) rename {tests => test}/scenes/data/poly-areas.osm (100%) rename {tests => test}/scenes/data/road-with-alley.wkt (100%) rename {tests => test}/scenes/data/roads-with-pois.wkt (100%) rename {tests => test}/scenes/data/roads.osm (100%) rename {tests => test}/scenes/data/split-road.wkt (100%) rename {tests => test}/scenes/data/way-area-with-center.wkt (100%) diff --git a/tests/scenes/bin/Makefile b/test/scenes/bin/Makefile similarity index 100% rename from tests/scenes/bin/Makefile rename to test/scenes/bin/Makefile diff --git a/tests/scenes/bin/make_scenes.sh b/test/scenes/bin/make_scenes.sh similarity index 100% rename from tests/scenes/bin/make_scenes.sh rename to test/scenes/bin/make_scenes.sh diff --git a/tests/scenes/bin/osm2wkt.cc b/test/scenes/bin/osm2wkt.cc similarity index 100% rename from tests/scenes/bin/osm2wkt.cc rename to test/scenes/bin/osm2wkt.cc diff --git a/tests/scenes/data/building-on-street-corner.wkt b/test/scenes/data/building-on-street-corner.wkt similarity index 100% rename from tests/scenes/data/building-on-street-corner.wkt rename to test/scenes/data/building-on-street-corner.wkt diff --git a/tests/scenes/data/building-with-parallel-streets.wkt b/test/scenes/data/building-with-parallel-streets.wkt similarity index 100% rename from tests/scenes/data/building-with-parallel-streets.wkt rename to test/scenes/data/building-with-parallel-streets.wkt diff --git a/tests/scenes/data/country.sql b/test/scenes/data/country.sql similarity index 100% rename from tests/scenes/data/country.sql rename to test/scenes/data/country.sql diff --git a/tests/scenes/data/country.wkt b/test/scenes/data/country.wkt similarity index 100% rename from tests/scenes/data/country.wkt rename to test/scenes/data/country.wkt diff --git a/tests/scenes/data/parallel-road.wkt b/test/scenes/data/parallel-road.wkt similarity index 100% rename from tests/scenes/data/parallel-road.wkt rename to test/scenes/data/parallel-road.wkt diff --git a/tests/scenes/data/points-on-roads.wkt b/test/scenes/data/points-on-roads.wkt similarity index 100% rename from tests/scenes/data/points-on-roads.wkt rename to test/scenes/data/points-on-roads.wkt diff --git a/tests/scenes/data/poly-area.wkt b/test/scenes/data/poly-area.wkt similarity index 100% rename from tests/scenes/data/poly-area.wkt rename to test/scenes/data/poly-area.wkt diff --git a/tests/scenes/data/poly-areas.osm b/test/scenes/data/poly-areas.osm similarity index 100% rename from tests/scenes/data/poly-areas.osm rename to test/scenes/data/poly-areas.osm diff --git a/tests/scenes/data/road-with-alley.wkt b/test/scenes/data/road-with-alley.wkt similarity index 100% rename from tests/scenes/data/road-with-alley.wkt rename to test/scenes/data/road-with-alley.wkt diff --git a/tests/scenes/data/roads-with-pois.wkt b/test/scenes/data/roads-with-pois.wkt similarity index 100% rename from tests/scenes/data/roads-with-pois.wkt rename to test/scenes/data/roads-with-pois.wkt diff --git a/tests/scenes/data/roads.osm b/test/scenes/data/roads.osm similarity index 100% rename from tests/scenes/data/roads.osm rename to test/scenes/data/roads.osm diff --git a/tests/scenes/data/split-road.wkt b/test/scenes/data/split-road.wkt similarity index 100% rename from tests/scenes/data/split-road.wkt rename to test/scenes/data/split-road.wkt diff --git a/tests/scenes/data/way-area-with-center.wkt b/test/scenes/data/way-area-with-center.wkt similarity index 100% rename from tests/scenes/data/way-area-with-center.wkt rename to test/scenes/data/way-area-with-center.wkt From 5252051291262562a3339f33ec284196f4a3c919 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Wed, 28 Dec 2016 23:41:08 +0100 Subject: [PATCH 26/29] add source files for test database --- test/testdb/README.md | 15 ++ test/testdb/testdb.polys | 188 +++++++++++++++++++++++++ test/testdb/wikipedia_article.sql.bin | Bin 0 -> 89889 bytes test/testdb/wikipedia_redirect.sql.bin | Bin 0 -> 44023 bytes 4 files changed, 203 insertions(+) create mode 100644 test/testdb/README.md create mode 100644 test/testdb/testdb.polys create mode 100644 test/testdb/wikipedia_article.sql.bin create mode 100644 test/testdb/wikipedia_redirect.sql.bin diff --git a/test/testdb/README.md b/test/testdb/README.md new file mode 100644 index 00000000..a39b0258 --- /dev/null +++ b/test/testdb/README.md @@ -0,0 +1,15 @@ +Creating the test database +========================== + +The official test dataset is derived from the 160725 planet. Newer +planets are likely to work as well but you may see isolated test +failures where the data has changed. To recreate the input data +for the test database run: + + wget http://free.nchc.org.tw/osm.planet/pbf/planet-160725.osm.pbf + osmconvert planet-160725.osm.pbf -B=testdb.polys -o=testdb.pbf + +Before importing make sure to add the following to your local settings: + + @define('CONST_Database_DSN', 'pgsql://@/test_api_nominatim'); + @define('CONST_Wikipedia_Data_Path', CONST_BasePath.'/test/testdb'); diff --git a/test/testdb/testdb.polys b/test/testdb/testdb.polys new file mode 100644 index 00000000..4298d68e --- /dev/null +++ b/test/testdb/testdb.polys @@ -0,0 +1,188 @@ +hamburg +1 + 9.5842804224817 53.5792118965693 + 10.2155260812517 53.8246176085747 + 10.475796519837 53.4477065812749 + 9.86815657040402 53.3278566584492 + 9.5842804224817 53.5792118965693 +END +END +liechtenstein +1 + 9.20853378041844 47.0559465458986 + 9.29384606832709 47.3507444175206 + 9.49848868129809 47.4492015884201 + 9.89867967626406 47.130228397937 + 9.58252408463202 46.8691824262863 + 9.20853378041844 47.0559465458986 +END +END +mauretania +1 + -17.1644809253606 20.8842205115601 + -16.9724694177095 21.4269590060279 + -13.1021317602129 21.4296172232924 + -13.1921945122782 22.933479308252 + -12.6268357994672 23.3657053906301 + -12.097953263953 23.5240373343518 + -12.0829087151283 26.0504750040867 + -8.76667736314239 26.0902806494621 + -8.70696146546581 27.398150020094 + -4.74082309576697 25.0335832288387 + -4.81791817472112 24.9110447612753 + -6.46188681458007 24.9097418021523 + -5.52019664485951 16.5528424294381 + -5.23944558032214 16.3345963721271 + -5.46430959536222 15.4167603246987 + -10.5098309475637 15.3543804758815 + -10.8990110578091 15.0165664449548 + -11.4524241105327 15.5284484048548 + -11.6506044402694 15.4177129737211 + -11.7217380574693 14.8443740855381 + -12.0680385872662 14.631162290351 + -12.9159650211166 15.1353675285383 + -13.4682080737434 15.990978880421 + -13.9034306609561 16.0335336430301 + -14.4204259536969 16.5367120778098 + -16.2084993845136 16.4236729998869 + -16.4302891135388 15.983629954068 + -16.7862990158802 16.0015581695182 + -16.3398604501701 18.2277294784962 + -16.8886326298423 19.403294102207 + -16.585950103956 20.1812628878206 + -17.0813413461875 20.5018960498623 + -17.1644809253606 20.8842205115601 +END +END +southdakota +1 + -104.297439187793 45.5046231666747 + -104.299553299189 45.9775616107873 + -104.096382287788 46.1214554410027 + -96.4569073278818 46.0989703969216 + -96.3044767039811 45.9115675974812 + -96.5247176872565 45.5862877816162 + -96.1968327207445 45.3001425265336 + -96.1895592635092 43.1140553398185 + -96.3329810429579 42.753709571028 + -96.2088431240585 42.4315154785281 + -96.5131423452368 42.3018249286814 + -97.3698668669773 42.6757095208851 + -98.0672608450503 42.5864899816312 + -98.6364176029847 42.8190548434256 + -104.146030684593 42.8349704964079 + -104.298744415682 43.0007971343175 + -104.297439187793 45.5046231666747 +END +END +uruguay +1 + -58.7094503498495 -33.5893528935793 + -58.5930297220504 -33.0935229194446 + -58.329380679061 -32.9223715673938 + -58.4160616358367 -31.8529190894557 + -58.2162152055053 -31.6271374679322 + -58.2872417410783 -31.4234579824293 + -58.0141967102111 -30.7805399817192 + -58.0801823804181 -30.438369563871 + -57.7465673402929 -30.0366166581386 + -57.3919054971047 -30.0920714480735 + -57.0841245854315 -29.904689506793 + -56.5203007925187 -30.0577138349604 + -55.8148965965951 -30.7486942236281 + -55.4992686810269 -30.6637735134172 + -55.0823825399047 -31.0951827436534 + -54.4609533378373 -31.3096231186724 + -52.8647106347639 -32.7122837473293 + -53.3056885052038 -33.2040687582016 + -53.3095867684494 -33.547639206286 + -52.9652990494926 -33.8719452856167 + -53.3564833683333 -34.6077542513996 + -55.6741509399751 -35.9609110600942 + -58.0955146798429 -34.8078487405856 + -58.1517292851949 -34.5120322638008 + -58.490557396808 -34.2574246976253 + -58.7094503498495 -33.5893528935793 +END +END + + +vietnam +1 + 111.737359200422 8.65966389848196 + 112.121385535871 9.03974154821598 + 112.431435613709 8.95854407291052 + 112.38091038991 8.72869141993135 + 112.09887919106 8.68752900955559 + 111.97764097397 8.47027379584868 + 111.737359200422 8.65966389848196)) + ((101.952539778161 22.3744843276505 + 102.42115782215 22.9512287654112 + 103.011765362757 22.7267436381062 + 103.309708309047 22.9942421815192 + 103.88404879754 22.8346923138744 + 104.62599830253 23.0355978361405 + 104.77147320329 23.3199292571035 + 105.357960240763 23.571602116825 + 105.914016228782 23.1523756402279 + 106.944000642084 22.9589139939517 + 106.898848084023 22.1610135134167 + 107.607567716302 21.7959107906373 + 107.974793702983 21.8006347108588 + 108.387669146529 21.2765369215022 + 108.350851164743 20.968001228975 + 107.807714207219 20.5205405897832 + 108.126799006308 20.25069143158 + 108.024397106408 19.8655870027683 + 107.557799016272 19.7778131848293 + 107.347203511678 19.9746481898397 + 107.334187929597 20.2503746970115 + 107.017188364275 20.2484351047036 + 106.88168214676 19.9452753668929 + 106.293211714996 19.5544614034104 + 106.229050701227 19.084494263666 + 106.339912028886 18.6544696395528 + 107.02787120458 18.0908270105482 + 106.992048357062 17.6610336194013 + 107.61356106657 17.4455208732243 + 109.497501740771 15.5635441996556 + 109.851305717105 12.5942782065681 + 109.388462041025 9.73419261849773 + 106.766311197844 8.27477170182912 + 104.780565299066 8.00956568214471 + 103.236641212428 8.93273491992321 + 102.869224286517 9.37238182036115 + 102.909736286172 9.54505245725422 + 103.717955146996 10.1271353150665 + 103.686354089781 10.5210100495922 + 104.015476398095 10.662190116791 + 104.247688572369 10.5092223585766 + 104.775438554966 10.7014491439673 + 104.980441972174 11.1056591699101 + 105.7024629092 11.2045497780933 + 105.626753980109 11.6300119740348 + 105.748149258633 11.8111016132902 + 107.378741221451 12.5115515071093 + 107.300732476862 12.958173736809 + 107.435951728865 13.4799057185978 + 107.150187488306 14.1107044150892 + 107.411164618718 15.2667546971578 + 107.034817435829 15.6854570535274 + 107.112895173759 15.9819504632758 + 106.538255811019 16.3296204491326 + 106.173473133351 17.0787384431658 + 105.048356842632 18.1992017641427 + 104.927464569104 18.5590394564598 + 103.69790542148 19.2392576346486 + 103.937765477152 19.8144348780526 + 104.649272403028 19.9254452865302 + 104.202109979964 20.3691301778448 + 104.304114148338 20.6519242141597 + 103.979630359435 20.7152945884418 + 103.65735416319 20.4809125958884 + 103.000528825335 20.7510959123255 + 102.627912181829 21.1973495084135 + 102.683177661723 21.4733395191357 + 101.952539778161 22.3744843276505 +END +END diff --git a/test/testdb/wikipedia_article.sql.bin b/test/testdb/wikipedia_article.sql.bin new file mode 100644 index 0000000000000000000000000000000000000000..628e2af4b7ead284e0deee365102b4e7ffc6f86a GIT binary patch literal 89889 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þofwfOLWJ<)Q!7(6RI^NzraZcJ3P{@h#^-eK; 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

hFr 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 UIbX0d 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 literal 0 HcmV?d00001 diff --git a/test/testdb/wikipedia_redirect.sql.bin b/test/testdb/wikipedia_redirect.sql.bin new file mode 100644 index 0000000000000000000000000000000000000000..9c4b513d3d0a319d61db9c3cfa7c56f751e02cec GIT binary patch literal 44023 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≺5nrP9u?}pb8ChzRczw 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 AEC2ui literal 0 HcmV?d00001 From b2be8c3ab7baca371ae2d2f65555e0a5ca09f531 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Fri, 30 Dec 2016 22:55:57 +0100 Subject: [PATCH 27/29] move php tests in common test dir and unify READMEs --- phpunit.xml | 4 +- test/README.md | 133 ++++++++++++++++++ test/bdd/db/import/parenting.feature | 12 +- test/bdd/environment.py | 1 - test/bdd/steps/queries.py | 2 +- .../php}/Nominatim/NominatimTest.php | 2 +- {tests-php => test/php}/bootstrap.php | 0 test/testdb/README.md | 15 -- tests-php/README.txt | 14 -- tests/README.md | 102 -------------- 10 files changed, 145 insertions(+), 140 deletions(-) create mode 100644 test/README.md rename {tests-php => test/php}/Nominatim/NominatimTest.php (99%) rename {tests-php => test/php}/bootstrap.php (100%) delete mode 100644 test/testdb/README.md delete mode 100644 tests-php/README.txt delete mode 100644 tests/README.md diff --git a/phpunit.xml b/phpunit.xml index bea876d5..addce5ce 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,13 +8,13 @@ processIsolation="false" stopOnFailure="false" syntaxCheck="true" - bootstrap="tests-php/bootstrap.php" + bootstrap="test/php/bootstrap.php" > - ./tests-php/Nominatim + ./test/php/Nominatim diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..49fb101d --- /dev/null +++ b/test/README.md @@ -0,0 +1,133 @@ +This directory contains functional and unit tests for the Nominatim API. + +Prerequisites +============= + + * Python 3 (https://www.python.org/) + * behave test framework >= 1.2.5 (https://github.com/behave/behave) + * nose (https://nose.readthedocs.org) + * pytidylib (http://countergram.com/open-source/pytidylib) + * psycopg2 (http://initd.org/psycopg/) + +To get the prerequisites on a a fresh Ubuntu LTS 16.04 run: + + [sudo] apt-get install python3-dev python3-pip python3-psycopg2 python3-tidylib phpunit + pip3 install --user behave nose + + +Overall structure +================= + +There are two kind of tests in this test suite. There are functional tests +which test the API interface using a BDD test framework and there are unit +tests for specific PHP functions. + +This test directory is sturctured as follows: + + -+- bdd Functional API tests + | \ + | +- steps Step implementations for test descriptions + | +- osm2pgsql Tests for data import via osm2pgsql + | +- db Tests for internal data processing on import and update + | +- api Tests for API endpoints (search, reverse, etc.) + | + +- php PHP unit tests + +- scenes Geometry test data + +- testdb Base data for generating API test database + + +PHP Unit Tests +============== + +Unit tests can be found in the php/ directory and tests selected php functions. +Very low coverage. + +To execute the test suite run + + cd test/php + phpunit ../ + +It will read phpunit.xml which points to the library, test path, bootstrap +strip and set other parameters. + + +BDD Functional Tests +==================== + +Functional tests are written as BDD instructions. For more information on +the philosophy of BDD testing, see http://pythonhosted.org/behave/philosophy.html + +Usage +----- + +To run the functional tests, do + + cd test/bdd + behave + +The tests can be configured with a set of environment variables: + + * `BUILD_DIR` - build directory of Nominatim installation to test + * `TEMPLATE_DB` - name of template database used as a skeleton for + the test databases (db tests) + * `TEST_DB` - name of test database (db tests) + * `ABI_TEST_DB` - name of the database containing the API test data (api tests) + * `TEST_SETTINGS_TEMPLATE` - file to write temporary Nominatim settings to + * `REMOVE_TEMPLATE` - if true, the template database will not be reused during + the next run. Reusing the base templates speeds up tests + considerably but might lead to outdated errors for some + changes in the database layout. + * `KEEP_TEST_DB` - if true, the test database will not be dropped after a test + is finished. Should only be used if one single scenario is + run, otherwise the result is undefined. + +Logging can be defined through command line parameters of behave itself. Check +out `behave --help` for details. Also keep an eye out for the 'work-in-progress' +feature of behave which comes in handy when writing new tests. + +Writing Tests +------------- + +The following explanation assume that the reader is familiar with the BDD +notations of features, scenarios and steps. + +All possible steps can be found in the `steps` directory and should ideally +be documented. + +### API Tests (`test/bdd/api`) + +These tests are meant to test the different API endpoints and their parameters. +They require a preimported test database, which consists of the import of a +planet extract. The polygons defining the extract can be found in the test/testdb +directory. There is also a reduced set of wikipedia data for this extract, +which you need to import as well. + +The official test dataset is derived from the 160725 planet. Newer +planets are likely to work as well but you may see isolated test +failures where the data has changed. To recreate the input data +for the test database run: + + wget http://free.nchc.org.tw/osm.planet/pbf/planet-160725.osm.pbf + osmconvert planet-160725.osm.pbf -B=test/testdb/testdb.polys -o=testdb.pbf + +Before importing make sure to add the following to your local settings: + + @define('CONST_Database_DSN', 'pgsql://@/test_api_nominatim'); + @define('CONST_Wikipedia_Data_Path', CONST_BasePath.'/test/testdb'); + +### Indexing Tests (`test/bdd/db`) + +These tests check the import and update of the Nominatim database. They do not +test the correctness of osm2pgsql. Each test will write some data into the `place` +table (and optionally `the planet_osm_*` tables if required) and then run +Nominatim's processing functions on that. + +These tests need to create their own test databases. By default they will be +called `test_template_nominatim` and `test_nominatim`. Names can be changed with +the environment variables `TEMPLATE_DB` and `TEST_DB`. The user running the tests +needs superuser rights for postgres. + +### Import Tests (`test/bdd/osm2pgsql`) + +These tests check that data is imported correctly into the place table. They +use the same template database as the Indexing tests, so the same remarks apply. diff --git a/test/bdd/db/import/parenting.feature b/test/bdd/db/import/parenting.feature index c00f4701..b0b76438 100644 --- a/test/bdd/db/import/parenting.feature +++ b/test/bdd/db/import/parenting.feature @@ -18,10 +18,14 @@ Feature: Parenting of objects | object | parent_place_id | | N1 | W1 | | N2 | W1 | - And search_name contains - | object | nameaddress_vector | - | N1 | 4, galoo, 12345 | - | N2 | 5, galoo, 99999 | + When searching for "4 galoo" + Then results contain + | ID | osm_type | osm_id | langaddress + | 0 | N | 1 | 4, galoo, 12345 + When searching for "5 galoo" + Then results contain + | ID | osm_type | osm_id | langaddress + | 0 | N | 2 | 5, galoo, 99999 Scenario: Address without tags, closest street Given the scene roads-with-pois diff --git a/test/bdd/environment.py b/test/bdd/environment.py index 69d93bd8..6411d011 100644 --- a/test/bdd/environment.py +++ b/test/bdd/environment.py @@ -10,7 +10,6 @@ from sys import version_info as python_version logger = logging.getLogger(__name__) userconfig = { - 'BASEURL' : 'http://localhost/nominatim', 'BUILDDIR' : os.path.join(os.path.split(__file__)[0], "../../build"), 'REMOVE_TEMPLATE' : False, 'KEEP_TEST_DB' : False, diff --git a/test/bdd/steps/queries.py b/test/bdd/steps/queries.py index 7d3dec69..c429f082 100644 --- a/test/bdd/steps/queries.py +++ b/test/bdd/steps/queries.py @@ -264,7 +264,7 @@ def send_api_query(endpoint, params, fmt, context): for h in context.table.headings: params[h] = context.table[0][h] - env = BASE_SERVER_ENV + env = dict(BASE_SERVER_ENV) env['QUERY_STRING'] = urlencode(params) env['SCRIPT_NAME'] = '/%s.php' % endpoint diff --git a/tests-php/Nominatim/NominatimTest.php b/test/php/Nominatim/NominatimTest.php similarity index 99% rename from tests-php/Nominatim/NominatimTest.php rename to test/php/Nominatim/NominatimTest.php index 7822c5dc..f8ba14c1 100644 --- a/tests-php/Nominatim/NominatimTest.php +++ b/test/php/Nominatim/NominatimTest.php @@ -2,7 +2,7 @@ namespace Nominatim; -require '../lib/lib.php'; +require '../../lib/lib.php'; class NominatimTest extends \PHPUnit_Framework_TestCase { diff --git a/tests-php/bootstrap.php b/test/php/bootstrap.php similarity index 100% rename from tests-php/bootstrap.php rename to test/php/bootstrap.php diff --git a/test/testdb/README.md b/test/testdb/README.md deleted file mode 100644 index a39b0258..00000000 --- a/test/testdb/README.md +++ /dev/null @@ -1,15 +0,0 @@ -Creating the test database -========================== - -The official test dataset is derived from the 160725 planet. Newer -planets are likely to work as well but you may see isolated test -failures where the data has changed. To recreate the input data -for the test database run: - - wget http://free.nchc.org.tw/osm.planet/pbf/planet-160725.osm.pbf - osmconvert planet-160725.osm.pbf -B=testdb.polys -o=testdb.pbf - -Before importing make sure to add the following to your local settings: - - @define('CONST_Database_DSN', 'pgsql://@/test_api_nominatim'); - @define('CONST_Wikipedia_Data_Path', CONST_BasePath.'/test/testdb'); diff --git a/tests-php/README.txt b/tests-php/README.txt deleted file mode 100644 index d551c1da..00000000 --- a/tests-php/README.txt +++ /dev/null @@ -1,14 +0,0 @@ -Basic unit tests of PHP code. Very low coverage. Doesn't cover interaction -with the webserver/HTTP or database (yet). - -You need to have -https://phpunit.de/manual/4.2/en/ -installed. - -To execute the test suite run -$ cd tests-php -$ phpunit ./ - -It will read phpunit.xml which points to the library, test path, bootstrap -strip and set other parameters. - diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 1b1663c3..00000000 --- a/tests/README.md +++ /dev/null @@ -1,102 +0,0 @@ -This directory contains functional tests for the Nominatim API, -for the import/update from osm files and for indexing. - -The tests use the lettuce framework (http://lettuce.it/) and -nose (https://nose.readthedocs.org). API tests are meant to be run -against a Nominatim installation with a complete planet-wide -setup based on a fairly recent planet. If you only have an -excerpt, some of the API tests may fail. Database tests can be -run without having a database installed. - -Prerequisites -============= - - * lettuce framework (http://lettuce.it/) - * nose (https://nose.readthedocs.org) - * pytidylib (http://countergram.com/open-source/pytidylib) - * haversine (https://github.com/mapado/haversine) - * shapely (https://github.com/Toblerity/Shapely) - -Usage -===== - - * get prerequisites - - # on a fresh Ubuntu LTS 14.04 you'll also need these system-wide packages - [sudo] apt-get install python-dev python-pip python-Levenshtein tidy - - [sudo] pip install lettuce nose pytidylib haversine psycopg2 shapely - - * run the tests - - NOMINATIM_SERVER=http://your.nominatim.instance/ lettuce features - -The tests can be configured with a set of environment variables: - - * `NOMINATIM_SERVER` - URL of the nominatim instance (API tests) - * `NOMINATIM_DIR` - source directory of Nominatim (import tests) - * `TEMPLATE_DB` - name of template database used as a skeleton for - the test databases (db tests) - * `TEST_DB` - name of test database (db tests) - * `NOMINATIM_SETTINGS` - file to write temporary Nominatim settings to (db tests) - * `NOMINATIM_REUSE_TEMPLATE` - if defined, the template database will not be - deleted after the test runs and reused during - the next run. This speeds up tests considerably - but might lead to outdated errors for some - changes in the database layout. - * `NOMINATIM_KEEP_SCENARIO_DB` - if defined, the test database will not be - dropped after a test is finished. Should - only be used if one single scenario is run, - otherwise the result is undefined. - * `LOGLEVEL` - set to 'debug' to get more verbose output (only works properly - when output to a logfile is configured) - * `LOGFILE` - sends debug output to the given file - -Writing Tests -============= - -The following explanation assume that the reader is familiar with the lettuce -notations of features, scenarios and steps. - -All possible steps can be found in the `steps` directory and should ideally -be documented. - - -API Tests (`features/api`) --------------------------- - -These tests are meant to test the different API calls and their parameters. - -There are two kind of steps defined for these tests: -request setup steps (see `steps/api_setup.py`) -and steps for checking results (see `steps/api_result.py`). - -Each scenario follows this simple sequence of steps: - - 1. One or more steps to define parameters and HTTP headers of the request. - These are cumulative, so you can use multiple steps. - 2. A single step to call the API. This sends a HTTP request to the configured - server and collects the answer. The cached parameters will be deleted, - to ensure that the setup works properly with scenario outlines. - 3. As many result checks as necessary. The result remains cached, so that - multiple tests can be added here. - -Indexing Tests (`features/db`) ---------------------------------------------------- - -These tests check the import and update of the Nominatim database. They do not -test the correctness of osm2pgsql. Each test will write some data into the `place` -table (and optionally `the planet_osm_*` tables if required) and then run -Nominatim's processing functions on that. - -These tests need to create their own test databases. By default they will be -called `test_template_nominatim` and `test_nominatim`. Names can be changed with -the environment variables `TEMPLATE_DB` and `TEST_DB`. The user running the tests -needs superuser rights for postgres. - - -Import Tests (`features/osm2pgsql`) ------------------------------------ - -These tests check that data is imported correctly into the place table. They -use the same template database as the Indexing tests, so the same remarks apply. From c0e4a74c713f2c6b9538cbbaf0d32d5c558d0dac Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Fri, 30 Dec 2016 23:15:41 +0100 Subject: [PATCH 28/29] add mention of required Tiger files for test database --- test/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/README.md b/test/README.md index 49fb101d..6e8f7f97 100644 --- a/test/README.md +++ b/test/README.md @@ -100,7 +100,8 @@ These tests are meant to test the different API endpoints and their parameters. They require a preimported test database, which consists of the import of a planet extract. The polygons defining the extract can be found in the test/testdb directory. There is also a reduced set of wikipedia data for this extract, -which you need to import as well. +which you need to import as well. For Tiger tests the data of South Dakota +is required. Get the Tiger files `46*`. The official test dataset is derived from the 160725 planet. Newer planets are likely to work as well but you may see isolated test From fadffeaa2da7ff122c6c32ef892c958ee2f5616e Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Fri, 30 Dec 2016 23:16:21 +0100 Subject: [PATCH 29/29] remove old bdd tests --- tests/features/api/details.feature | 13 - tests/features/api/language.feature | 100 ---- tests/features/api/lookup.feature | 15 - tests/features/api/regression.feature | 217 ------- tests/features/api/reverse.feature | 148 ----- tests/features/api/reverse_by_id.feature | 13 - tests/features/api/reverse_simple.feature | 140 ----- tests/features/api/search.feature | 86 --- tests/features/api/search_order.feature | 36 -- tests/features/api/search_params.feature | 315 ---------- tests/features/api/search_simple.feature | 238 -------- tests/features/api/search_structured.feature | 41 -- .../features/db/import/interpolation.feature | 327 ----------- tests/features/db/import/linking.feature | 112 ---- tests/features/db/import/naming.feature | 193 ------ tests/features/db/import/parenting.feature | 458 --------------- tests/features/db/import/placex.feature | 318 ---------- tests/features/db/import/search_terms.feature | 42 -- tests/features/db/import/simple.feature | 17 - .../features/db/update/interpolation.feature | 258 -------- .../features/db/update/linked_places.feature | 96 --- tests/features/db/update/naming.feature | 39 -- tests/features/db/update/search_terms.feature | 117 ---- tests/features/db/update/simple.feature | 73 --- .../features/osm2pgsql/import/broken.feature | 37 -- .../osm2pgsql/import/relation.feature | 13 - .../features/osm2pgsql/import/simple.feature | 68 --- tests/features/osm2pgsql/import/tags.feature | 550 ------------------ .../osm2pgsql/update/relation.feature | 152 ----- .../features/osm2pgsql/update/simple.feature | 22 - tests/steps/api_result.py | 286 --------- tests/steps/api_setup.py | 136 ----- tests/steps/db_results.py | 201 ------- tests/steps/db_setup.py | 278 --------- tests/steps/osm2pgsql_setup.py | 214 ------- tests/steps/terrain.py | 255 -------- 36 files changed, 5624 deletions(-) delete mode 100644 tests/features/api/details.feature delete mode 100644 tests/features/api/language.feature delete mode 100644 tests/features/api/lookup.feature delete mode 100644 tests/features/api/regression.feature delete mode 100644 tests/features/api/reverse.feature delete mode 100644 tests/features/api/reverse_by_id.feature delete mode 100644 tests/features/api/reverse_simple.feature delete mode 100644 tests/features/api/search.feature delete mode 100644 tests/features/api/search_order.feature delete mode 100644 tests/features/api/search_params.feature delete mode 100644 tests/features/api/search_simple.feature delete mode 100644 tests/features/api/search_structured.feature delete mode 100644 tests/features/db/import/interpolation.feature delete mode 100644 tests/features/db/import/linking.feature delete mode 100644 tests/features/db/import/naming.feature delete mode 100644 tests/features/db/import/parenting.feature delete mode 100644 tests/features/db/import/placex.feature delete mode 100644 tests/features/db/import/search_terms.feature delete mode 100644 tests/features/db/import/simple.feature delete mode 100644 tests/features/db/update/interpolation.feature delete mode 100644 tests/features/db/update/linked_places.feature delete mode 100644 tests/features/db/update/naming.feature delete mode 100644 tests/features/db/update/search_terms.feature delete mode 100644 tests/features/db/update/simple.feature delete mode 100644 tests/features/osm2pgsql/import/broken.feature delete mode 100644 tests/features/osm2pgsql/import/relation.feature delete mode 100644 tests/features/osm2pgsql/import/simple.feature delete mode 100644 tests/features/osm2pgsql/import/tags.feature delete mode 100644 tests/features/osm2pgsql/update/relation.feature delete mode 100644 tests/features/osm2pgsql/update/simple.feature delete mode 100644 tests/steps/api_result.py delete mode 100644 tests/steps/api_setup.py delete mode 100644 tests/steps/db_results.py delete mode 100644 tests/steps/db_setup.py delete mode 100644 tests/steps/osm2pgsql_setup.py delete mode 100644 tests/steps/terrain.py diff --git a/tests/features/api/details.feature b/tests/features/api/details.feature deleted file mode 100644 index e59659c3..00000000 --- a/tests/features/api/details.feature +++ /dev/null @@ -1,13 +0,0 @@ -Feature: Object details - Check details page for correctness - - Scenario Outline: Details via OSM id - When looking up details for - Then the result is valid - - Examples: - | object - | 1758375 - | N158845944 - | W72493656 - | R62422 diff --git a/tests/features/api/language.feature b/tests/features/api/language.feature deleted file mode 100644 index 529dc021..00000000 --- a/tests/features/api/language.feature +++ /dev/null @@ -1,100 +0,0 @@ -Feature: Localization of search results - - Scenario: Search - default language - When sending json search query "Germany" - Then results contain - | ID | display_name - | 0 | Deutschland.* - - Scenario: Search - accept-language first - Given the request parameters - | accept-language - | en,de - When sending json search query "Deutschland" - Then results contain - | ID | display_name - | 0 | Germany.* - - Scenario: Search - accept-language missing - Given the request parameters - | accept-language - | xx,fr,en,de - When sending json search query "Deutschland" - Then results contain - | ID | display_name - | 0 | Allemagne.* - - Scenario: Search - http accept language header first - Given the HTTP header - | accept-language - | fr-ca,fr;q=0.8,en-ca;q=0.5,en;q=0.3 - When sending json search query "Deutschland" - Then results contain - | ID | display_name - | 0 | Allemagne.* - - Scenario: Search - http accept language header and accept-language - Given the request parameters - | accept-language - | de,en - Given the HTTP header - | accept-language - | fr-ca,fr;q=0.8,en-ca;q=0.5,en;q=0.3 - When sending json search query "Deutschland" - Then results contain - | ID | display_name - | 0 | Deutschland.* - - Scenario: Search - http accept language header fallback - Given the HTTP header - | accept-language - | fr-ca,en-ca;q=0.5 - When sending json search query "Deutschland" - Then results contain - | ID | display_name - | 0 | Allemagne.* - - Scenario: Search - http accept language header fallback (upper case) - Given the HTTP header - | accept-language - | fr-FR;q=0.8,en-ca;q=0.5 - When sending json search query "Deutschland" - Then results contain - | ID | display_name - | 0 | Allemagne.* - - Scenario: Reverse - default language - When looking up coordinates 48.13921,11.57328 - Then result addresses contain - | ID | city - | 0 | München - - Scenario: Reverse - accept-language parameter - Given the request parameters - | accept-language - | en,fr - When looking up coordinates 48.13921,11.57328 - Then result addresses contain - | ID | city - | 0 | Munich - - Scenario: Reverse - HTTP accept language header - Given the HTTP header - | accept-language - | fr-ca,fr;q=0.8,en-ca;q=0.5,en;q=0.3 - When looking up coordinates 48.13921,11.57328 - Then result addresses contain - | ID | city - | 0 | Munich - - Scenario: Reverse - accept-language parameter and HTTP header - Given the request parameters - | accept-language - | it - Given the HTTP header - | accept-language - | fr-ca,fr;q=0.8,en-ca;q=0.5,en;q=0.3 - When looking up coordinates 48.13921,11.57328 - Then result addresses contain - | ID | city - | 0 | Monaco di Baviera diff --git a/tests/features/api/lookup.feature b/tests/features/api/lookup.feature deleted file mode 100644 index 7b86fb49..00000000 --- a/tests/features/api/lookup.feature +++ /dev/null @@ -1,15 +0,0 @@ -Feature: Places by osm_type and osm_id Tests - Simple tests for internal server errors and response format. - - Scenario: address lookup for existing node, way, relation - When looking up xml places N158845944,W72493656,,R62422,X99,N0 - Then the result is valid xml - exactly 3 results are returned - When looking up json places N158845944,W72493656,,R62422,X99,N0 - Then the result is valid json - exactly 3 results are returned - - Scenario: address lookup for non-existing or invalid node, way, relation - When looking up xml places X99,,N0,nN158845944,ABC,,W9 - Then the result is valid xml - exactly 0 results are returned \ No newline at end of file diff --git a/tests/features/api/regression.feature b/tests/features/api/regression.feature deleted file mode 100644 index 08156d62..00000000 --- a/tests/features/api/regression.feature +++ /dev/null @@ -1,217 +0,0 @@ -Feature: API regression tests - Tests error cases reported in tickets. - - Scenario: trac #2430 - When sending json search query "89 River Avenue, Hoddesdon, Hertfordshire, EN11 0JT" - Then at least 1 result is returned - - Scenario: trac #2440 - When sending json search query "East Harvard Avenue, Denver" - Then more than 2 results are returned - - Scenario: trac #2456 - When sending xml search query "Borlänge Kommun" - Then results contain - | ID | place_rank - | 0 | 19 - - Scenario: trac #2530 - When sending json search query "Lange Straße, Bamberg" with address - Then result addresses contain - | ID | town - | 0 | Bamberg - - Scenario: trac #2541 - When sending json search query "pad, germany" - Then results contain - | ID | class | display_name - | 0 | aeroway | Paderborn/Lippstadt,.* - - Scenario: trac #2579 - When sending json search query "Johnsons Close, hackbridge" with address - Then result addresses contain - | ID | postcode - | 0 | SM5 2LU - - @Fail - Scenario Outline: trac #2586 - When sending json search query "" with address - Then result addresses contain - | ID | country_code - | 0 | uk - - Examples: - | query - | DL7 0SN - | DL70SN - - Scenario: trac #2628 (1) - When sending json search query "Adam Kraft Str" with address - Then result addresses contain - | ID | road - | 0 | Adam-Kraft-Straße - - Scenario: trac #2628 (2) - When sending json search query "Maxfeldstr. 5, Nürnberg" with address - Then result addresses contain - | ID | house_number | road | city - | 0 | 5 | Maxfeldstraße | Nürnberg - - Scenario: trac #2638 - When sending json search query "Nöthnitzer Str. 40, 01187 Dresden" with address - Then result addresses contain - | ID | house_number | road | city - | 0 | 40 | Nöthnitzer Straße | Dresden - - Scenario Outline: trac #2667 - When sending json search query "" with address - Then result addresses contain - | ID | house_number - | 0 | - - Examples: - | number | query - | 16 | 16 Woodpecker Way, Cambourne - | 14906 | 14906, 114 Street Northwest, Edmonton, Alberta, Canada - | 14904 | 14904, 114 Street Northwest, Edmonton, Alberta, Canada - | 15022 | 15022, 114 Street Northwest, Edmonton, Alberta, Canada - | 15024 | 15024, 114 Street Northwest, Edmonton, Alberta, Canada - - Scenario: trac #2681 - When sending json search query "kirchstraße troisdorf Germany" - Then results contain - | ID | display_name - | 0 | .*, Troisdorf, .* - - Scenario: trac #2758 - When sending json search query "6а, полуботка, чернигов" with address - Then result addresses contain - | ID | house_number - | 0 | 6а - - Scenario: trac #2790 - When looking up coordinates 49.0942079697809,8.27565898861822 - Then result addresses contain - | ID | road | village | country - | 0 | Daimlerstraße | Jockgrim | Deutschland - - Scenario: trac #2794 - When sending json search query "4008" - Then results contain - | ID | class | type - | 0 | place | postcode - - Scenario: trac #2797 - When sending json search query "Philippstr.4, 52349 Düren" with address - Then result addresses contain - | ID | road | town - | 0 | Philippstraße | Düren - - Scenario: trac #2830 - When sending json search query "207, Boardman Street, S0J 1L0, CA" with address - Then result addresses contain - | ID | house_number | road | postcode | country - | 0 | 207 | Boardman Street | S0J 1L0 | Canada - - Scenario: trac #2830 - When sending json search query "S0J 1L0,CA" - Then results contain - | ID | class | type | display_name - | 0 | place | postcode | .*, Canada - - Scenario: trac #2845 - When sending json search query "Leliestraat 31, Zwolle" with address - Then result addresses contain - | ID | city - | 0 | Zwolle - - Scenario: trac #2852 - When sending json search query "berlinerstrasse, leipzig" with address - Then result addresses contain - | ID | road - | 0 | Berliner Straße - - Scenario: trac #2871 - When looking up coordinates -33.906895553,150.99609375 - Then result addresses contain - | ID | city | country - | 0 | [^0-9]*$ | Australia - - Scenario: trac #2981 - When sending json search query "Ohmstraße 7, Berlin" with address - Then at least 2 results are returned - And result addresses contain - | house_number | road | state - | 7 | Ohmstraße | Berlin - - Scenario: trac #3049 - When sending json search query "Soccer City" - Then results contain - | ID | class | type | latlon - | 0 | leisure | stadium | -26.2347261,27.982645 +-50m - - Scenario: trac #3130 - When sending json search query "Old Way, Frinton" - Then results contain - | ID | class | latlon - | 0 | highway | 51.8324206,1.2447352 +-100m - - Scenario Outline: trac #5025 - When sending json search query "Kriegsstr , Karlsruhe" with address - Then result addresses contain - | house_number | road - | | Kriegsstraße - - Examples: - | house_nr - | 5c - | 25 - | 78 - | 80 - | 99 - | 130 - | 153 - | 196 - | 256 - | 294 - - Scenario: trac #5238 - Given the request parameters - | bounded | viewbox - | 1 | -1,0,0,-1 - When sending json search query "sy" - Then exactly 0 results are returned - - Scenario: trac #5274 - When sending json search query "Goedestraat 41-BS, Utrecht" with address - Then result addresses contain - | house_number | road | city - | 41-BS | Goedestraat | Utrecht - - @poldi-only - Scenario Outline: github #36 - When sending json search query "" with address - Then result addresses contain - | ID | road | city - | 0 | Seegasse | .*Wieselburg-Land - - Examples: - | query - | Seegasse, Gemeinde Wieselburg-Land - | Seegasse, Wieselburg-Land - | Seegasse, Wieselburg - - Scenario: github #190 - When looking up place N257363453 - Then the results contain - | osm_type | osm_id | latlon - | node | 257363453 | 35.8404121,128.5586643 +-100m - - Scenario: trac #5427 - Given the request parameters - | countrycodes | - | DE | - When sending json search query "12345" with address - Then result addresses contain - | country_code | - | de | diff --git a/tests/features/api/reverse.feature b/tests/features/api/reverse.feature deleted file mode 100644 index 7bd12913..00000000 --- a/tests/features/api/reverse.feature +++ /dev/null @@ -1,148 +0,0 @@ -Feature: Reverse geocoding - Testing the reverse function - - # Make sure country is not overwritten by the postcode - Scenario: Country is returned - Given the request parameters - | accept-language - | de - When looking up coordinates 53.9788769,13.0830313 - Then result addresses contain - | ID | country - | 0 | Deutschland - - - Scenario: Boundingbox is returned - Given the request parameters - | format | zoom - | xml | 4 - When looking up coordinates 53.9788769,13.0830313 - And results contain valid boundingboxes - - Scenario: Reverse geocoding for odd interpolated housenumber - - Scenario: Reverse geocoding for even interpolated housenumber - - @Tiger - Scenario: TIGER house number - Given the request parameters - | addressdetails - | 1 - When looking up jsonv2 coordinates 40.6863624710666,-112.060005720023 - And exactly 1 result is returned - And result addresses contain - | ID | house_number | road | postcode | country_code - | 0 | 709. | Kings Estate Drive | 84128 | us - And results contain - | osm_type | category | type - | way | place | house - - @Tiger - Scenario: No TIGER house number for zoom < 18 - Given the request parameters - | addressdetails | zoom - | 1 | 17 - When looking up coordinates 40.6863624710666,-112.060005720023 - And exactly 1 result is returned - And result addresses contain - | ID | road | postcode | country_code - | 0 | Kings Estate Drive | 84128 | us - And result 0 has attributes osm_id,osm_type - - Scenario Outline: Reverse Geocoding with extratags - Given the request parameters - | extratags - | 1 - When looking up coordinates 48.86093,2.2978 - Then result 0 has attributes extratags - - Examples: - | format - | xml - | json - | jsonv2 - - Scenario Outline: Reverse Geocoding with namedetails - Given the request parameters - | namedetails - | 1 - When looking up coordinates 48.86093,2.2978 - Then result 0 has attributes namedetails - - Examples: - | format - | xml - | json - | jsonv2 - - - Scenario Outline: Reverse Geocoding contains TEXT geometry - Given the request parameters - | polygon_text - | 1 - When looking up coordinates 48.86093,2.2978 - Then result 0 has attributes - - Examples: - | format | response_attribute - | xml | geotext - | json | geotext - | jsonv2 | geotext - - Scenario Outline: Reverse Geocoding contains polygon-as-points geometry - Given the request parameters - | polygon - | 1 - When looking up coordinates 48.86093,2.2978 - Then result 0 has not attributes - - Examples: - | format | response_attribute - | xml | polygonpoints - | json | polygonpoints - | jsonv2 | polygonpoints - - - - Scenario Outline: Reverse Geocoding contains SVG geometry - Given the request parameters - | polygon_svg - | 1 - When looking up coordinates 48.86093,2.2978 - Then result 0 has attributes - - Examples: - | format | response_attribute - | xml | geosvg - | json | svg - | jsonv2 | svg - - - Scenario Outline: Reverse Geocoding contains KML geometry - Given the request parameters - | polygon_kml - | 1 - When looking up coordinates 48.86093,2.2978 - Then result 0 has attributes - - Examples: - | format | response_attribute - | xml | geokml - | json | geokml - | jsonv2 | geokml - - - Scenario Outline: Reverse Geocoding contains GEOJSON geometry - Given the request parameters - | polygon_geojson - | 1 - When looking up coordinates 48.86093,2.2978 - Then result 0 has attributes - - Examples: - | format | response_attribute - | xml | geojson - | json | geojson - | jsonv2 | geojson - - diff --git a/tests/features/api/reverse_by_id.feature b/tests/features/api/reverse_by_id.feature deleted file mode 100644 index 5f5a8f8d..00000000 --- a/tests/features/api/reverse_by_id.feature +++ /dev/null @@ -1,13 +0,0 @@ -Feature: Reverse lookup by ID - Testing reverse geocoding via OSM ID - - # see github issue #269 - Scenario: Get address of linked places - Given the request parameters - | osm_type | osm_id - | N | 151421301 - When sending an API call reverse - Then exactly 1 result is returned - And result addresses contain - | county | state - | Pratt County | Kansas diff --git a/tests/features/api/reverse_simple.feature b/tests/features/api/reverse_simple.feature deleted file mode 100644 index 6100f54c..00000000 --- a/tests/features/api/reverse_simple.feature +++ /dev/null @@ -1,140 +0,0 @@ -Feature: Simple Reverse Tests - Simple tests for internal server errors and response format. - These tests should pass on any Nominatim installation. - - Scenario Outline: Simple reverse-geocoding - When looking up xml coordinates , - Then the result is valid xml - When looking up json coordinates , - Then the result is valid json - When looking up jsonv2 coordinates , - Then the result is valid json - - Examples: - | lat | lon - | 0.0 | 0.0 - | 45.3 | 3.5 - | -79.34 | 23.5 - | 0.23 | -178.555 - - Scenario Outline: Testing different parameters - Given the request parameters - | - | - When sending search query "Manchester" - Then the result is valid html - Given the request parameters - | - | - When sending html search query "Manchester" - Then the result is valid html - Given the request parameters - | - | - When sending xml search query "Manchester" - Then the result is valid xml - Given the request parameters - | - | - When sending json search query "Manchester" - Then the result is valid json - Given the request parameters - | - | - When sending jsonv2 search query "Manchester" - Then the result is valid json - - Examples: - | parameter | value - | polygon | 1 - | polygon | 0 - | polygon_text | 1 - | polygon_text | 0 - | polygon_kml | 1 - | polygon_kml | 0 - | polygon_geojson | 1 - | polygon_geojson | 0 - | polygon_svg | 1 - | polygon_svg | 0 - - - - - Scenario Outline: Wrapping of legal jsonp requests - Given the request parameters - | json_callback - | foo - When looking up coordinates 67.3245,0.456 - Then the result is valid json - - Examples: - | format - | json - | jsonv2 - - Scenario: Reverse-geocoding without address - Given the request parameters - | addressdetails - | 0 - When looking up xml coordinates 36.791966,127.171726 - Then the result is valid xml - When looking up json coordinates 36.791966,127.171726 - Then the result is valid json - When looking up jsonv2 coordinates 36.791966,127.171726 - Then the result is valid json - - Scenario: Reverse-geocoding with zoom - Given the request parameters - | zoom - | 10 - When looking up xml coordinates 36.791966,127.171726 - Then the result is valid xml - When looking up json coordinates 36.791966,127.171726 - Then the result is valid json - When looking up jsonv2 coordinates 36.791966,127.171726 - Then the result is valid json - - Scenario: Missing lon parameter - Given the request parameters - | lat - | 51.51 - When sending an API call reverse - Then a HTTP 400 is returned - - Scenario: Missing lat parameter - Given the request parameters - | lon - | -79.39114 - When sending an API call reverse - Then a HTTP 400 is returned - - Scenario: Missing osm_id parameter - Given the request parameters - | osm_type - | N - When sending an API call reverse - Then a HTTP 400 is returned - - Scenario: Missing osm_type parameter - Given the request parameters - | osm_id - | 3498564 - When sending an API call reverse - Then a HTTP 400 is returned - - Scenario Outline: Bad format for lat or lon - Given the request parameters - | lat | lon | - | | | - When sending an API call reverse - Then a HTTP 400 is returned - - Examples: - | lat | lon - | 48.9660 | 8,4482 - | 48,9660 | 8.4482 - | 48,9660 | 8,4482 - | 48.966.0 | 8.4482 - | 48.966 | 8.448.2 - | Nan | 8.448 - | 48.966 | Nan diff --git a/tests/features/api/search.feature b/tests/features/api/search.feature deleted file mode 100644 index 91050daf..00000000 --- a/tests/features/api/search.feature +++ /dev/null @@ -1,86 +0,0 @@ -Feature: Search queries - Testing correctness of results - - Scenario: UK House number search - When sending json search query "27 Thoresby Road, Broxtowe" with address - Then address of result 0 contains - | type | value - | house_number | 27 - | road | Thoresby Road - | city | Broxtowe - | state | England - | country | U.*K.* - | country_code | gb - - - Scenario: House number search for non-street address - Given the request parameters - | accept-language - | en - When sending json search query "4 Pomocnia, Pokrzywnica, Poland" with address - Then address of result 0 contains - | type | value - | house_number | 4 - | county | gmina Pokrzywnica - | state | Masovian Voivodeship - | postcode | 06-121 - | country | Poland - | country_code | pl - Then address of result 0 does not contain road - - Scenario: House number interpolation even - Given the request parameters - | accept-language - | en - When sending json search query "140 rue Don Bosco, Saguenay" with address - Then address of result 0 contains - | type | value - | house_number | 140 - | road | [Rr]ue Don Bosco - | city | .*Saguenay - | state | Quebec - | country | Canada - | country_code | ca - - Scenario: House number interpolation odd - Given the request parameters - | accept-language - | en - When sending json search query "141 rue Don Bosco, Saguenay" with address - Then address of result 0 contains - | type | value - | house_number | 141 - | road | [rR]ue Don Bosco - | city | .*Saguenay - | state | Quebec - | country | Canada - | country_code | ca - - @Tiger - Scenario: TIGER house number - When sending json search query "3 West Victory Way, Craig" - Then results contain - | osm_type - | way - - @Tiger - Scenario: TIGER house number (road fallback) - When sending json search query "3030 West Victory Way, Craig" - Then results contain - | osm_type - | way - - Scenario: Expansion of Illinois - Given the request parameters - | accept-language - | en - When sending json search query "il, us" - Then results contain - | ID | display_name - | 0 | Illinois.* - - Scenario: Search with class-type feature - When sending jsonv2 search query "Hotel California" - Then results contain - | place_rank - | 30 diff --git a/tests/features/api/search_order.feature b/tests/features/api/search_order.feature deleted file mode 100644 index fad5e89c..00000000 --- a/tests/features/api/search_order.feature +++ /dev/null @@ -1,36 +0,0 @@ -Feature: Result order for Geocoding - Testing that importance ordering returns sensible results - - Scenario Outline: city order in street search - Given the request parameters - | limit - | 100 - When sending json search query ", " with address - Then address of result 0 contains - | type | value - | | - - Examples: - | type | city | street - | city | Zürich | Rigistr - | city | Karlsruhe | Sophienstr - | city | München | Karlstr - | city | Praha | Dlouhá - - Scenario Outline: use more important city in street search - When sending json search query ", " with address - Then result addresses contain - | ID | country_code - | 0 | - - Examples: - | country | city | street - | gb | London | Main St - | gb | Manchester | Central Street - - # https://trac.openstreetmap.org/ticket/5094 - Scenario: housenumbers are ordered by complete match first - When sending json search query "4 Докукина Москва" with address - Then result addresses contain - | ID | house_number - | 0 | 4 diff --git a/tests/features/api/search_params.feature b/tests/features/api/search_params.feature deleted file mode 100644 index cd0db091..00000000 --- a/tests/features/api/search_params.feature +++ /dev/null @@ -1,315 +0,0 @@ -Feature: Search queries - Testing different queries and parameters - - Scenario: Simple XML search - When sending xml search query "Schaan" - Then result 0 has attributes place_id,osm_type,osm_id - And result 0 has attributes place_rank,boundingbox - And result 0 has attributes lat,lon,display_name - And result 0 has attributes class,type,importance,icon - And result 0 has not attributes address - And results contain valid boundingboxes - - Scenario: Simple JSON search - When sending json search query "Vaduz" - And result 0 has attributes place_id,licence,icon,class,type - And result 0 has attributes osm_type,osm_id,boundingbox - And result 0 has attributes lat,lon,display_name,importance - And result 0 has not attributes address - And results contain valid boundingboxes - - Scenario: JSON search with addressdetails - When sending json search query "Montevideo" with address - Then address of result 0 is - | type | value - | city | Montevideo - | state | Montevideo - | country | Uruguay - | country_code | uy - - Scenario: XML search with addressdetails - When sending xml search query "Inuvik" with address - Then address of result 0 contains - | type | value - | state | Northwest Territories - | country | Canada - | country_code | ca - - Scenario: coordinate search with addressdetails - When sending json search query "51.193058013916,15.5245780944824" with address - Then result addresses contain - | village | country | country_code - | Kraszowice | Polska | pl - - Scenario: Address details with unknown class types - When sending json search query "foobar, Essen" with address - Then results contain - | ID | class | type - | 0 | leisure | hackerspace - And result addresses contain - | ID | address29 - | 0 | Chaospott - And address of result 0 does not contain leisure,hackerspace - - Scenario: Disabling deduplication - When sending json search query "Oxford Street, London" - Then there are no duplicates - Given the request parameters - | dedupe - | 0 - When sending json search query "Oxford Street, London" - Then there are duplicates - - Scenario: Search with bounded viewbox in right area - Given the request parameters - | bounded | viewbox - | 1 | -87.7,41.9,-87.57,41.85 - When sending json search query "restaurant" with address - Then result addresses contain - | ID | city - | 0 | Chicago - - Scenario: Search with bounded viewboxlbrt in right area - Given the request parameters - | bounded | viewboxlbrt - | 1 | -87.7,41.85,-87.57,41.9 - When sending json search query "restaurant" with address - Then result addresses contain - | ID | city - | 0 | Chicago - - Scenario: No POI search with unbounded viewbox - Given the request parameters - | viewbox - | -87.7,41.9,-87.57,41.85 - When sending json search query "restaurant" - Then results contain - | display_name - | [^,]*(?i)restaurant.* - - Scenario: bounded search remains within viewbox, even with no results - Given the request parameters - | bounded | viewbox - | 1 | 43.5403125,-5.6563282,43.54285,-5.662003 - When sending json search query "restaurant" - Then less than 1 result is returned - - Scenario: bounded search remains within viewbox with results - Given the request parameters - | bounded | viewbox - | 1 | -5.662003,43.55,-5.6563282,43.5403125 - When sending json search query "restaurant" - | lon | lat - | >= -5.662003 | >= 43.5403125 - | <= -5.6563282| <= 43.55 - - Scenario: Prefer results within viewbox - Given the request parameters - | accept-language - | en - When sending json search query "royan" with address - Then result addresses contain - | ID | country - | 0 | France - Given the request parameters - | accept-language | viewbox - | en | 51.94,36.59,51.99,36.56 - When sending json search query "royan" with address - Then result addresses contain - | ID | country - | 0 | Iran - - Scenario: Overly large limit number for search results - Given the request parameters - | limit - | 1000 - When sending json search query "Neustadt" - Then at most 50 results are returned - - Scenario: Limit number of search results - Given the request parameters - | limit - | 4 - When sending json search query "Neustadt" - Then exactly 4 results are returned - - Scenario: Restrict to feature type country - Given the request parameters - | featureType - | country - When sending xml search query "Monaco" - Then results contain - | place_rank - | 4 - - Scenario: Restrict to feature type state - When sending xml search query "Berlin" - Then results contain - | ID | place_rank - | 0 | 1[56] - Given the request parameters - | featureType - | state - When sending xml search query "Berlin" - Then results contain - | place_rank - | [78] - - Scenario: Restrict to feature type city - Given the request parameters - | featureType - | city - When sending xml search query "Monaco" - Then results contain - | place_rank - | 1[56789] - - - Scenario: Restrict to feature type settlement - When sending json search query "Everest" - Then results contain - | ID | display_name - | 0 | Mount Everest.* - Given the request parameters - | featureType - | settlement - When sending json search query "Everest" - Then results contain - | ID | display_name - | 0 | Everest.* - - Scenario Outline: Search with polygon threshold (json) - Given the request parameters - | polygon_geojson | polygon_threshold - | 1 |

- When sending json search query "switzerland" - Then at least 1 result is returned - And result 0 has attributes geojson - - Examples: - | th - | -1 - | 0.0 - | 0.5 - | 999 - - Scenario Outline: Search with polygon threshold (xml) - Given the request parameters - | polygon_geojson | polygon_threshold - | 1 | - When sending xml search query "switzerland" - Then at least 1 result is returned - And result 0 has attributes geojson - - Examples: - | th - | -1 - | 0.0 - | 0.5 - | 999 - - Scenario Outline: Search with invalid polygon threshold (xml) - Given the request parameters - | polygon_geojson | polygon_threshold - | 1 | - When sending xml search query "switzerland" - Then a HTTP 400 is returned - - - Scenario Outline: Search with extratags - Given the request parameters - | extratags - | 1 - When sending search query "Hauptstr" - Then result 0 has attributes extratags - And result 1 has attributes extratags - - Examples: - | format - | xml - | json - | jsonv2 - - Scenario Outline: Search with namedetails - Given the request parameters - | namedetails - | 1 - When sending search query "Hauptstr" - Then result 0 has attributes namedetails - And result 1 has attributes namedetails - - Examples: - | format - | xml - | json - | jsonv2 - - - Scenario Outline: Search result with contains TEXT geometry - Given the request parameters - | polygon_text - | 1 - When sending search query "switzerland" - Then result 0 has attributes - - Examples: - | format | response_attribute - | xml | geotext - | json | geotext - | jsonv2 | geotext - - Scenario Outline: Search result contains polygon-as-points geometry - Given the request parameters - | polygon - | 1 - When sending search query "switzerland" - Then result 0 has attributes - - Examples: - | format | response_attribute - | xml | polygonpoints - | json | polygonpoints - | jsonv2 | polygonpoints - - - - Scenario Outline: Search result contains SVG geometry - Given the request parameters - | polygon_svg - | 1 - When sending search query "switzerland" - Then result 0 has attributes - - Examples: - | format | response_attribute - | xml | geosvg - | json | svg - | jsonv2 | svg - - - Scenario Outline: Search result contains KML geometry - Given the request parameters - | polygon_kml - | 1 - When sending search query "switzerland" - Then result 0 has attributes - - Examples: - | format | response_attribute - | xml | geokml - | json | geokml - | jsonv2 | geokml - - - Scenario Outline: Search result contains GEOJSON geometry - Given the request parameters - | polygon_geojson - | 1 - When sending search query "switzerland" - Then result 0 has attributes - - Examples: - | format | response_attribute - | xml | geojson - | json | geojson - | jsonv2 | geojson diff --git a/tests/features/api/search_simple.feature b/tests/features/api/search_simple.feature deleted file mode 100644 index 0020cc2e..00000000 --- a/tests/features/api/search_simple.feature +++ /dev/null @@ -1,238 +0,0 @@ -Feature: Simple Tests - Simple tests for internal server errors and response format. - These tests should pass on any Nominatim installation. - - Scenario Outline: Testing different parameters - Given the request parameters - | - | - When sending search query "Manchester" - Then the result is valid html - Given the request parameters - | - | - When sending html search query "Manchester" - Then the result is valid html - Given the request parameters - | - | - When sending xml search query "Manchester" - Then the result is valid xml - Given the request parameters - | - | - When sending json search query "Manchester" - Then the result is valid json - Given the request parameters - | - | - When sending jsonv2 search query "Manchester" - Then the result is valid json - - Examples: - | parameter | value - | addressdetails | 1 - | addressdetails | 0 - | polygon | 1 - | polygon | 0 - | polygon_text | 1 - | polygon_text | 0 - | polygon_kml | 1 - | polygon_kml | 0 - | polygon_geojson | 1 - | polygon_geojson | 0 - | polygon_svg | 1 - | polygon_svg | 0 - | accept-language | de,en - | countrycodes | uk,ir - | bounded | 1 - | bounded | 0 - | exclude_place_ids| 385252,1234515 - | limit | 1000 - | dedupe | 1 - | dedupe | 0 - | extratags | 1 - | extratags | 0 - | namedetails | 1 - | namedetails | 0 - - Scenario: Search with invalid output format - Given the request parameters - | format - | fd$# - When sending search query "Berlin" - Then a HTTP 400 is returned - - Scenario Outline: Simple Searches - When sending search query "" - Then the result is valid html - When sending html search query "" - Then the result is valid html - When sending xml search query "" - Then the result is valid xml - When sending json search query "" - Then the result is valid json - When sending jsonv2 search query "" - Then the result is valid json - - Examples: - | query - | New York, New York - | France - | 12, Main Street, Houston - | München - | 東京都 - | hotels in nantes - | xywxkrf - | gh; foo() - | %#$@*&l;der#$! - | 234 - | 47.4,8.3 - - Scenario: Empty XML search - When sending xml search query "xnznxvcx" - Then result header contains - | attr | value - | querystring | xnznxvcx - | polygon | false - | more_url | .*format=xml.*q=xnznxvcx.* - - Scenario: Empty XML search with special XML characters - When sending xml search query "xfdghn&zxn"xvbyxcssdex" - Then result header contains - | attr | value - | querystring | xfdghn&zxn"xvbyxcssdex - | polygon | false - | more_url | .*format=xml.*q=xfdghn&zxn"xvbyxcssdex.* - - Scenario: Empty XML search with viewbox - Given the request parameters - | viewbox - | 12,45.13,77,33 - When sending xml search query "xnznxvcx" - Then result header contains - | attr | value - | querystring | xnznxvcx - | polygon | false - | viewbox | 12,45.13,77,33 - - Scenario: Empty XML search with viewboxlbrt - Given the request parameters - | viewboxlbrt - | 12,34.13,77,45 - When sending xml search query "xnznxvcx" - Then result header contains - | attr | value - | querystring | xnznxvcx - | polygon | false - | viewbox | 12,45.13,77,33 - - Scenario: Empty XML search with viewboxlbrt and viewbox - Given the request parameters - | viewbox | viewboxblrt - | 12,45.13,77,33 | 1,2,3,4 - When sending xml search query "pub" - Then result header contains - | attr | value - | querystring | pub - | polygon | false - | viewbox | 12,45.13,77,33 - - - Scenario Outline: Empty XML search with polygon values - Given the request parameters - | polygon - | - When sending xml search query "xnznxvcx" - Then result header contains - | attr | value - | polygon | - - Examples: - | result | polyval - | false | 0 - | true | 1 - | true | True - | true | true - | true | false - | true | FALSE - | true | yes - | true | no - | true | '; delete from foobar; select ' - - - Scenario: Empty XML search with exluded place ids - Given the request parameters - | exclude_place_ids - | 123,76,342565 - When sending xml search query "jghrleoxsbwjer" - Then result header contains - | attr | value - | exclude_place_ids | 123,76,342565 - - Scenario: Empty XML search with bad exluded place ids - Given the request parameters - | exclude_place_ids - | , - When sending xml search query "jghrleoxsbwjer" - Then result header has no attribute exclude_place_ids - - Scenario Outline: Wrapping of legal jsonp search requests - Given the request parameters - | json_callback - | - When sending json search query "Tokyo" - Then there is a json wrapper "" - - Examples: - | data - | foo - | FOO - | __world - | $me - | m1[4] - | d_r[$d] - - Scenario Outline: Wrapping of illegal jsonp search requests - Given the request parameters - | json_callback - | - When sending json search query "Tokyo" - Then a HTTP 400 is returned - - Examples: - | data - | 1asd - | bar(foo) - | XXX['bad'] - | foo; evil - - Scenario Outline: Ignore jsonp parameter for anything but json - Given the request parameters - | json_callback - | 234 - When sending json search query "Malibu" - Then a HTTP 400 is returned - Given the request parameters - | json_callback - | 234 - When sending xml search query "Malibu" - Then the result is valid xml - Given the request parameters - | json_callback - | 234 - When sending html search query "Malibu" - Then the result is valid html - - Scenario: Empty JSON search - When sending json search query "YHlERzzx" - Then exactly 0 results are returned - - Scenario: Empty JSONv2 search - When sending jsonv2 search query "Flubb XdfESSaZx" - Then exactly 0 results are returned - - Scenario: Search for non-existing coordinates - When sending json search query "-21.0,-33.0" - Then exactly 0 results are returned - diff --git a/tests/features/api/search_structured.feature b/tests/features/api/search_structured.feature deleted file mode 100644 index 27e5d344..00000000 --- a/tests/features/api/search_structured.feature +++ /dev/null @@ -1,41 +0,0 @@ -Feature: Structured search queries - Testing correctness of results with - structured queries - - Scenario: Country only - When sending json structured query with address - | country - | Canada - Then address of result 0 is - | type | value - | country | Canada - | country_code | ca - - Scenario: Postcode only - When sending json structured query with address - | postalcode - | 22547 - Then at least 1 result is returned - And results contain - | type - | post(al_)?code - And result addresses contain - | postcode - | 22547 - - - Scenario: Street, postcode and country - When sending xml structured query with address - | street | postalcode | country - | Old Palace Road | GU2 7UP | United Kingdom - Then at least 1 result is returned - Then result header contains - | attr | value - | querystring | Old Palace Road, GU2 7UP, United Kingdom - - - Scenario: gihub #176 - When sending json structured query with address - | city - | Washington - Then at least 1 result is returned diff --git a/tests/features/db/import/interpolation.feature b/tests/features/db/import/interpolation.feature deleted file mode 100644 index 6974e7be..00000000 --- a/tests/features/db/import/interpolation.feature +++ /dev/null @@ -1,327 +0,0 @@ -@DB -Feature: Import of address interpolations - Tests that interpolated addresses are added correctly - - Scenario: Simple even interpolation line with two points - Given the place nodes - | osm_id | osm_type | class | type | housenumber | geometry - | 1 | N | place | house | 2 | 1 1 - | 2 | N | place | house | 6 | 1 1.001 - And the place ways - | osm_id | osm_type | class | type | housenumber | geometry - | 1 | W | place | houses | even | 1 1, 1 1.001 - And the ways - | id | nodes - | 1 | 1,2 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 2 | 6 | 1 1, 1 1.001 - - Scenario: Backwards even two point interpolation line - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | 1 1 - | 2 | place | house | 6 | 1 1.001 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | even | 1 1.001, 1 1 - And the ways - | id | nodes - | 1 | 2,1 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 2 | 6 | 1 1, 1 1.001 - - Scenario: Simple odd two point interpolation - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 1 | 1 1 - | 2 | place | house | 11 | 1 1.001 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | odd | 1 1, 1 1.001 - And the ways - | id | nodes - | 1 | 1,2 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 1 | 11 | 1 1, 1 1.001 - - Scenario: Simple all two point interpolation - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 1 | 1 1 - | 2 | place | house | 3 | 1 1.001 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | all | 1 1, 1 1.001 - And the ways - | id | nodes - | 1 | 1,2 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 1 | 3 | 1 1, 1 1.001 - - Scenario: Even two point interpolation line with intermediate empty node - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | 1 1 - | 2 | place | house | 10 | 1.001 1.001 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | even | 1 1, 1 1.001, 1.001 1.001 - And the ways - | id | nodes - | 1 | 1,3,2 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 2 | 10 | 1 1, 1 1.001, 1.001 1.001 - - Scenario: Even two point interpolation line with intermediate duplicated empty node - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | 1 1 - | 2 | place | house | 10 | 1.001 1.001 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | even | 1 1, 1 1.001, 1.001 1.001 - And the ways - | id | nodes - | 1 | 1,3,3,2 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 2 | 10 | 1 1, 1 1.001, 1.001 1.001 - - Scenario: Simple even three point interpolation line - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | 1 1 - | 2 | place | house | 14 | 1.001 1.001 - | 3 | place | house | 10 | 1 1.001 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | even | 1 1, 1 1.001, 1.001 1.001 - And the ways - | id | nodes - | 1 | 1,3,2 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 2 | 10 | 1 1, 1 1.001 - | 10 | 14 | 1 1.001, 1.001 1.001 - - Scenario: Simple even four point interpolation line - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | 1 1 - | 2 | place | house | 14 | 1.001 1.001 - | 3 | place | house | 10 | 1 1.001 - | 4 | place | house | 18 | 1.001 1.002 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | even | 1 1, 1 1.001, 1.001 1.001, 1.001 1.002 - And the ways - | id | nodes - | 1 | 1,3,2,4 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 2 | 10 | 1 1, 1 1.001 - | 10 | 14 | 1 1.001, 1.001 1.001 - | 14 | 18 | 1.001 1.001, 1.001 1.002 - - Scenario: Reverse simple even three point interpolation line - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | 1 1 - | 2 | place | house | 14 | 1.001 1.001 - | 3 | place | house | 10 | 1 1.001 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | even | 1.001 1.001, 1 1.001, 1 1 - And the ways - | id | nodes - | 1 | 2,3,1 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 2 | 10 | 1 1, 1 1.001 - | 10 | 14 | 1 1.001, 1.001 1.001 - - Scenario: Even three point interpolation line with odd center point - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | 1 1 - | 2 | place | house | 8 | 1.001 1.001 - | 3 | place | house | 7 | 1 1.001 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | even | 1 1, 1 1.001, 1.001 1.001 - And the ways - | id | nodes - | 1 | 1,3,2 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 2 | 7 | 1 1, 1 1.001 - | 7 | 8 | 1 1.001, 1.001 1.001 - - Scenario: Interpolation line with self-intersecting way - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | 0 0 - | 2 | place | house | 6 | 0 0.001 - | 3 | place | house | 10 | 0 0.002 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | even | 0 0, 0 0.001, 0 0.002, 0 0.001 - And the ways - | id | nodes - | 1 | 1,2,3,2 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 2 | 6 | 0 0, 0 0.001 - | 6 | 10 | 0 0.001, 0 0.002 - | 6 | 10 | 0 0.001, 0 0.002 - - Scenario: Interpolation line with self-intersecting way II - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | 0 0 - | 2 | place | house | 6 | 0 0.001 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | even | 0 0, 0 0.001, 0 0.002, 0 0.001 - And the ways - | id | nodes - | 1 | 1,2,3,2 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 2 | 6 | 0 0, 0 0.001 - - Scenario: addr:street on interpolation way - Given the scene parallel-road - And the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | :n-middle-w - | 2 | place | house | 6 | :n-middle-e - | 3 | place | house | 12 | :n-middle-w - | 4 | place | house | 16 | :n-middle-e - And the place ways - | osm_id | class | type | housenumber | street | geometry - | 10 | place | houses | even | | :w-middle - | 11 | place | houses | even | Cloud Street | :w-middle - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | tertiary | 'name' : 'Sun Way' | :w-north - | 3 | highway | tertiary | 'name' : 'Cloud Street' | :w-south - And the ways - | id | nodes - | 10 | 1,100,101,102,2 - | 11 | 3,200,201,202,4 - When importing - Then table placex contains - | object | parent_place_id - | N1 | W2 - | N2 | W2 - | N3 | W3 - | N4 | W3 - Then table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W10 | W2 | 2 | 6 - | W11 | W3 | 12 | 16 - When sending query "16 Cloud Street" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 4 - When sending query "14 Cloud Street" - Then results contain - | ID | osm_type | osm_id - | 0 | W | 11 - When sending query "18 Cloud Street" - Then results contain - | ID | osm_type | osm_id - | 0 | W | 3 - - Scenario: addr:street on housenumber way - Given the scene parallel-road - And the place nodes - | osm_id | class | type | housenumber | street | geometry - | 1 | place | house | 2 | | :n-middle-w - | 2 | place | house | 6 | | :n-middle-e - | 3 | place | house | 12 | Cloud Street | :n-middle-w - | 4 | place | house | 16 | Cloud Street | :n-middle-e - And the place ways - | osm_id | class | type | housenumber | geometry - | 10 | place | houses | even | :w-middle - | 11 | place | houses | even | :w-middle - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | tertiary | 'name' : 'Sun Way' | :w-north - | 3 | highway | tertiary | 'name' : 'Cloud Street' | :w-south - And the ways - | id | nodes - | 10 | 1,100,101,102,2 - | 11 | 3,200,201,202,4 - When importing - Then table placex contains - | object | parent_place_id - | N1 | W2 - | N2 | W2 - | N3 | W3 - | N4 | W3 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W10 | W2 | 2 | 6 - | W11 | W3 | 12 | 16 - When sending query "16 Cloud Street" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 4 - When sending query "14 Cloud Street" - Then results contain - | ID | osm_type | osm_id - | 0 | W | 11 - - Scenario: Geometry of points and way don't match (github #253) - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 10 | 144.9632341 -37.76163 - | 2 | place | house | 6 | 144.9630541 -37.7628174 - | 3 | shop | supermarket | 2 | 144.9629794 -37.7630755 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | even | 144.9632341 -37.76163,144.9630541 -37.7628172,144.9629794 -37.7630755 - And the ways - | id | nodes - | 1 | 1,2,3 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 2 | 6 | 144.9629794 -37.7630755, 144.9630541 -37.7628174 - | 6 | 10 | 144.9630541 -37.7628174, 144.9632341 -37.76163 - - Scenario: Place with missing address information - Given the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 23 | 0.0001 0.0001 - | 2 | amenity | school | | 0.0001 0.0002 - | 3 | place | house | 29 | 0.0001 0.0004 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | odd | 0.0001 0.0001,0.0001 0.0002,0.0001 0.0004 - And the ways - | id | nodes - | 1 | 1,2,3 - When importing - Then way 1 expands to lines - | startnumber | endnumber | geometry - | 23 | 29 | 0.0001 0.0001, 0.0001 0.0002, 0.0001 0.0004 diff --git a/tests/features/db/import/linking.feature b/tests/features/db/import/linking.feature deleted file mode 100644 index 299087ae..00000000 --- a/tests/features/db/import/linking.feature +++ /dev/null @@ -1,112 +0,0 @@ -@DB -Feature: Linking of places - Tests for correctly determining linked places - - Scenario: Only address-describing places can be linked - Given the scene way-area-with-center - And the place areas - | osm_type | osm_id | class | type | name | geometry - | R | 13 | landuse | forest | Garbo | :area - And the place nodes - | osm_id | class | type | name | geometry - | 256 | natural | peak | Garbo | :inner-C - When importing - Then table placex contains - | object | linked_place_id - | R13 | None - | N256 | None - - Scenario: Waterways are linked when in waterway relations - Given the scene split-road - And the place ways - | osm_type | osm_id | class | type | name | geometry - | W | 1 | waterway | river | Rhein | :w-2 - | W | 2 | waterway | river | Rhein | :w-3 - | R | 13 | waterway | river | Rhein | :w-1 + :w-2 + :w-3 - | R | 23 | waterway | river | Limmat| :w-4a - And the relations - | id | members | tags - | 13 | R23:tributary,W1,W2:main_stream | 'type' : 'waterway' - When importing - Then table placex contains - | object | linked_place_id - | W1 | R13 - | W2 | R13 - | R13 | None - | R23 | None - When sending query "rhein" - Then results contain - | osm_type - | R - - Scenario: Relations are not linked when in waterway relations - Given the scene split-road - And the place ways - | osm_type | osm_id | class | type | name | geometry - | W | 1 | waterway | river | Rhein | :w-2 - | W | 2 | waterway | river | Rhein | :w-3 - | R | 1 | waterway | river | Rhein | :w-1 + :w-2 + :w-3 - | R | 2 | waterway | river | Limmat| :w-4a - And the relations - | id | members | tags - | 1 | R2 | 'type' : 'waterway' - When importing - Then table placex contains - | object | linked_place_id - | W1 | None - | W2 | None - | R1 | None - | R2 | None - - Scenario: Empty waterway relations are handled correctly - Given the scene split-road - And the place ways - | osm_type | osm_id | class | type | name | geometry - | R | 1 | waterway | river | Rhein | :w-1 + :w-2 + :w-3 - And the relations - | id | members | tags - | 1 | | 'type' : 'waterway' - When importing - Then table placex contains - | object | linked_place_id - | R1 | None - - Scenario: Waterways are not linked when waterway types don't match - Given the scene split-road - And the place ways - | osm_type | osm_id | class | type | name | geometry - | W | 1 | waterway | drain | Rhein | :w-2 - | R | 1 | waterway | river | Rhein | :w-1 + :w-2 + :w-3 - And the relations - | id | members | tags - | 1 | N23,N34,W1,R45 | 'type' : 'multipolygon' - When importing - Then table placex contains - | object | linked_place_id - | W1 | None - | R1 | None - When sending query "rhein" - Then results contain - | ID | osm_type - | 0 | R - | 1 | W - - Scenario: Side streams are linked only when they have the same name - Given the scene split-road - And the place ways - | osm_type | osm_id | class | type | name | geometry - | W | 1 | waterway | river | Rhein2 | :w-2 - | W | 2 | waterway | river | Rhein | :w-3 - | R | 1 | waterway | river | Rhein | :w-1 + :w-2 + :w-3 - And the relations - | id | members | tags - | 1 | W1:side_stream,W2:side_stream | 'type' : 'waterway' - When importing - Then table placex contains - | object | linked_place_id - | W1 | None - | W2 | R1 - When sending query "rhein2" - Then results contain - | osm_type - | W diff --git a/tests/features/db/import/naming.feature b/tests/features/db/import/naming.feature deleted file mode 100644 index 64a3f8b1..00000000 --- a/tests/features/db/import/naming.feature +++ /dev/null @@ -1,193 +0,0 @@ -@DB -Feature: Import and search of names - Tests all naming related issues: normalisation, - abbreviations, internationalisation, etc. - - - Scenario: Case-insensitivity of search - Given the place nodes - | osm_id | class | type | name - | 1 | place | locality | 'name' : 'FooBar' - When importing - Then table placex contains - | object | class | type | name - | N1 | place | locality | 'name' : 'FooBar' - When sending query "FooBar" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - When sending query "foobar" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - When sending query "fOObar" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - When sending query "FOOBAR" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - - Scenario: Multiple spaces in name - Given the place nodes - | osm_id | class | type | name - | 1 | place | locality | 'name' : 'one two three' - When importing - When sending query "one two three" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - When sending query "one two three" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - When sending query "one two three" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - When sending query " one two three" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - - Scenario: Special characters in name - Given the place nodes - | osm_id | class | type | name - | 1 | place | locality | 'name' : 'Jim-Knopf-Str' - | 2 | place | locality | 'name' : 'Smith/Weston' - | 3 | place | locality | 'name' : 'space mountain' - | 4 | place | locality | 'name' : 'space' - | 5 | place | locality | 'name' : 'mountain' - When importing - When sending query "Jim-Knopf-Str" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - When sending query "Jim Knopf-Str" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - When sending query "Jim Knopf Str" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - When sending query "Jim/Knopf-Str" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - When sending query "Jim-Knopfstr" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - When sending query "Smith/Weston" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 2 - When sending query "Smith Weston" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 2 - When sending query "Smith-Weston" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 2 - When sending query "space mountain" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 3 - When sending query "space-mountain" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 3 - When sending query "space/mountain" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 3 - When sending query "space\mountain" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 3 - When sending query "space(mountain)" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 3 - - Scenario: No copying name tag if only one name - Given the place nodes - | osm_id | class | type | name | geometry - | 1 | place | locality | 'name' : 'german' | country:de - When importing - Then table placex contains - | object | calculated_country_code | - | N1 | de - And table placex contains as names for N1 - | object | k | v - | N1 | name | german - - Scenario: Copying name tag to default language if it does not exist - Given the place nodes - | osm_id | class | type | name | geometry - | 1 | place | locality | 'name' : 'german', 'name:fi' : 'finnish' | country:de - When importing - Then table placex contains - | object | calculated_country_code | - | N1 | de - And table placex contains as names for N1 - | k | v - | name | german - | name:fi | finnish - | name:de | german - - Scenario: Copying default language name tag to name if it does not exist - Given the place nodes - | osm_id | class | type | name | geometry - | 1 | place | locality | 'name:de' : 'german', 'name:fi' : 'finnish' | country:de - When importing - Then table placex contains - | object | calculated_country_code | - | N1 | de - And table placex contains as names for N1 - | k | v - | name | german - | name:fi | finnish - | name:de | german - - Scenario: Do not overwrite default language with name tag - Given the place nodes - | osm_id | class | type | name | geometry - | 1 | place | locality | 'name' : 'german', 'name:fi' : 'finnish', 'name:de' : 'local' | country:de - When importing - Then table placex contains - | object | calculated_country_code | - | N1 | de - And table placex contains as names for N1 - | k | v - | name | german - | name:fi | finnish - | name:de | local - - Scenario: Landuse with name are found - Given the place areas - | osm_type | osm_id | class | type | name | geometry - | R | 1 | natural | meadow | 'name' : 'landuse1' | (0 0, 1 0, 1 1, 0 1, 0 0) - | R | 2 | landuse | industrial | 'name' : 'landuse2' | (0 0, -1 0, -1 -1, 0 -1, 0 0) - When importing - When sending query "landuse1" - Then results contain - | ID | osm_type | osm_id - | 0 | R | 1 - When sending query "landuse2" - Then results contain - | ID | osm_type | osm_id - | 0 | R | 2 - - Scenario: Postcode boundaries without ref - Given the place areas - | osm_type | osm_id | class | type | postcode | geometry - | R | 1 | boundary | postal_code | 12345 | (0 0, 1 0, 1 1, 0 1, 0 0) - When importing - When sending query "12345" - Then results contain - | ID | osm_type | osm_id - | 0 | R | 1 diff --git a/tests/features/db/import/parenting.feature b/tests/features/db/import/parenting.feature deleted file mode 100644 index 36754f84..00000000 --- a/tests/features/db/import/parenting.feature +++ /dev/null @@ -1,458 +0,0 @@ -@DB -Feature: Parenting of objects - Tests that the correct parent is choosen - - Scenario: Address inherits postcode from its street unless it has a postcode - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 4 | :p-N1 - And the place nodes - | osm_id | class | type | housenumber | postcode | geometry - | 2 | place | house | 5 | 99999 | :p-N1 - And the place ways - | osm_id | class | type | name | postcode | geometry - | 1 | highway | residential | galoo | 12345 | :w-north - When importing - Then table placex contains - | object | parent_place_id - | N1 | W1 - | N2 | W1 - When sending query "4 galoo" - Then results contain - | ID | osm_type | osm_id | langaddress - | 0 | N | 1 | 4, galoo, 12345 - When sending query "5 galoo" - Then results contain - | ID | osm_type | osm_id | langaddress - | 0 | N | 2 | 5, galoo, 99999 - - - Scenario: Address without tags, closest street - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | geometry - | 1 | place | house | :p-N1 - | 2 | place | house | :p-N2 - | 3 | place | house | :p-S1 - | 4 | place | house | :p-S2 - And the named place ways - | osm_id | class | type | geometry - | 1 | highway | residential | :w-north - | 2 | highway | residential | :w-south - When importing - Then table placex contains - | object | parent_place_id - | N1 | W1 - | N2 | W1 - | N3 | W2 - | N4 | W2 - - Scenario: Address without tags avoids unnamed streets - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | geometry - | 1 | place | house | :p-N1 - | 2 | place | house | :p-N2 - | 3 | place | house | :p-S1 - | 4 | place | house | :p-S2 - And the place ways - | osm_id | class | type | geometry - | 1 | highway | residential | :w-north - And the named place ways - | osm_id | class | type | geometry - | 2 | highway | residential | :w-south - When importing - Then table placex contains - | object | parent_place_id - | N1 | W2 - | N2 | W2 - | N3 | W2 - | N4 | W2 - - Scenario: addr:street tag parents to appropriately named street - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | street| geometry - | 1 | place | house | south | :p-N1 - | 2 | place | house | north | :p-N2 - | 3 | place | house | south | :p-S1 - | 4 | place | house | north | :p-S2 - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | north | :w-north - | 2 | highway | residential | south | :w-south - When importing - Then table placex contains - | object | parent_place_id - | N1 | W2 - | N2 | W1 - | N3 | W2 - | N4 | W1 - - Scenario: addr:street tag parents to next named street - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | street | geometry - | 1 | place | house | abcdef | :p-N1 - | 2 | place | house | abcdef | :p-N2 - | 3 | place | house | abcdef | :p-S1 - | 4 | place | house | abcdef | :p-S2 - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | abcdef | :w-north - | 2 | highway | residential | abcdef | :w-south - When importing - Then table placex contains - | object | parent_place_id - | N1 | W1 - | N2 | W1 - | N3 | W2 - | N4 | W2 - - Scenario: addr:street tag without appropriately named street - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | street | geometry - | 1 | place | house | abcdef | :p-N1 - | 2 | place | house | abcdef | :p-N2 - | 3 | place | house | abcdef | :p-S1 - | 4 | place | house | abcdef | :p-S2 - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | abcde | :w-north - | 2 | highway | residential | abcde | :w-south - When importing - Then table placex contains - | object | parent_place_id - | N1 | W1 - | N2 | W1 - | N3 | W2 - | N4 | W2 - - Scenario: addr:place address - Given the scene road-with-alley - And the place nodes - | osm_id | class | type | addr_place | geometry - | 1 | place | house | myhamlet | :n-alley - And the place nodes - | osm_id | class | type | name | geometry - | 2 | place | hamlet | myhamlet | :n-main-west - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | myhamlet | :w-main - When importing - Then table placex contains - | object | parent_place_id - | N1 | N2 - - Scenario: addr:street is preferred over addr:place - Given the scene road-with-alley - And the place nodes - | osm_id | class | type | addr_place | street | geometry - | 1 | place | house | myhamlet | mystreet| :n-alley - And the place nodes - | osm_id | class | type | name | geometry - | 2 | place | hamlet | myhamlet | :n-main-west - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | mystreet | :w-main - When importing - Then table placex contains - | object | parent_place_id - | N1 | W1 - - Scenario: Untagged address in simple associated street relation - Given the scene road-with-alley - And the place nodes - | osm_id | class | type | geometry - | 1 | place | house | :n-alley - | 2 | place | house | :n-corner - | 3 | place | house | :n-main-west - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | foo | :w-main - | 2 | highway | service | bar | :w-alley - And the relations - | id | members | tags - | 1 | W1:street,N1,N2,N3 | 'type' : 'associatedStreet' - When importing - Then table placex contains - | object | parent_place_id - | N1 | W1 - | N2 | W1 - | N3 | W1 - - Scenario: Avoid unnamed streets in simple associated street relation - Given the scene road-with-alley - And the place nodes - | osm_id | class | type | geometry - | 1 | place | house | :n-alley - | 2 | place | house | :n-corner - | 3 | place | house | :n-main-west - And the named place ways - | osm_id | class | type | geometry - | 1 | highway | residential | :w-main - And the place ways - | osm_id | class | type | geometry - | 2 | highway | residential | :w-alley - And the relations - | id | members | tags - | 1 | N1,N2,N3,W2:street,W1:street | 'type' : 'associatedStreet' - When importing - Then table placex contains - | object | parent_place_id - | N1 | W1 - | N2 | W1 - | N3 | W1 - - ### Scenario 10 - Scenario: Associated street relation overrides addr:street - Given the scene road-with-alley - And the place nodes - | osm_id | class | type | street | geometry - | 1 | place | house | bar | :n-alley - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | foo | :w-main - | 2 | highway | residential | bar | :w-alley - And the relations - | id | members | tags - | 1 | W1:street,N1,N2,N3 | 'type' : 'associatedStreet' - When importing - Then table placex contains - | object | parent_place_id - | N1 | W1 - - Scenario: Building without tags, closest street from center point - Given the scene building-on-street-corner - And the named place ways - | osm_id | class | type | geometry - | 1 | building | yes | :w-building - | 2 | highway | primary | :w-WE - | 3 | highway | residential | :w-NS - When importing - Then table placex contains - | object | parent_place_id - | W1 | W3 - - Scenario: Building with addr:street tags - Given the scene building-on-street-corner - And the named place ways - | osm_id | class | type | street | geometry - | 1 | building | yes | bar | :w-building - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | primary | bar | :w-WE - | 3 | highway | residential | foo | :w-NS - When importing - Then table placex contains - | object | parent_place_id - | W1 | W2 - - Scenario: Building with addr:place tags - Given the scene building-on-street-corner - And the place nodes - | osm_id | class | type | name | geometry - | 1 | place | village | bar | :n-outer - And the named place ways - | osm_id | class | type | addr_place | geometry - | 1 | building | yes | bar | :w-building - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | primary | bar | :w-WE - | 3 | highway | residential | foo | :w-NS - When importing - Then table placex contains - | object | parent_place_id - | W1 | N1 - - Scenario: Building in associated street relation - Given the scene building-on-street-corner - And the named place ways - | osm_id | class | type | geometry - | 1 | building | yes | :w-building - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | primary | bar | :w-WE - | 3 | highway | residential | foo | :w-NS - And the relations - | id | members | tags - | 1 | W1:house,W2:street | 'type' : 'associatedStreet' - When importing - Then table placex contains - | object | parent_place_id - | W1 | W2 - - Scenario: Building in associated street relation overrides addr:street - Given the scene building-on-street-corner - And the named place ways - | osm_id | class | type | street | geometry - | 1 | building | yes | foo | :w-building - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | primary | bar | :w-WE - | 3 | highway | residential | foo | :w-NS - And the relations - | id | members | tags - | 1 | W1:house,W2:street | 'type' : 'associatedStreet' - When importing - Then table placex contains - | object | parent_place_id - | W1 | W2 - - Scenario: Wrong member in associated street relation is ignored - Given the scene building-on-street-corner - And the named place nodes - | osm_id | class | type | geometry - | 1 | place | house | :n-outer - And the named place ways - | osm_id | class | type | street | geometry - | 1 | building | yes | foo | :w-building - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | primary | bar | :w-WE - | 3 | highway | residential | foo | :w-NS - And the relations - | id | members | tags - | 1 | N1:house,W1:street,W3:street | 'type' : 'associatedStreet' - When importing - Then table placex contains - | object | parent_place_id - | N1 | W3 - - Scenario: POIs in building inherit address - Given the scene building-on-street-corner - And the named place nodes - | osm_id | class | type | geometry - | 1 | amenity | bank | :n-inner - | 2 | shop | bakery | :n-edge-NS - | 3 | shop | supermarket| :n-edge-WE - And the place ways - | osm_id | class | type | street | addr_place | housenumber | geometry - | 1 | building | yes | foo | nowhere | 3 | :w-building - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | primary | bar | :w-WE - | 3 | highway | residential | foo | :w-NS - When importing - Then table placex contains - | object | parent_place_id | street | addr_place | housenumber - | W1 | W3 | foo | nowhere | 3 - | N1 | W3 | foo | nowhere | 3 - | N2 | W3 | foo | nowhere | 3 - | N3 | W3 | foo | nowhere | 3 - - Scenario: POIs don't inherit from streets - Given the scene building-on-street-corner - And the named place nodes - | osm_id | class | type | geometry - | 1 | amenity | bank | :n-inner - And the place ways - | osm_id | class | type | street | addr_place | housenumber | geometry - | 1 | highway | path | foo | nowhere | 3 | :w-building - And the place ways - | osm_id | class | type | name | geometry - | 3 | highway | residential | foo | :w-NS - When importing - Then table placex contains - | object | parent_place_id | street | addr_place | housenumber - | N1 | W3 | None | None | None - - Scenario: POIs with own address do not inherit building address - Given the scene building-on-street-corner - And the named place nodes - | osm_id | class | type | street | geometry - | 1 | amenity | bank | bar | :n-inner - And the named place nodes - | osm_id | class | type | housenumber | geometry - | 2 | shop | bakery | 4 | :n-edge-NS - And the named place nodes - | osm_id | class | type | addr_place | geometry - | 3 | shop | supermarket| nowhere | :n-edge-WE - And the place nodes - | osm_id | class | type | name | geometry - | 4 | place | isolated_dwelling | theplace | :n-outer - And the place ways - | osm_id | class | type | addr_place | housenumber | geometry - | 1 | building | yes | theplace | 3 | :w-building - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | primary | bar | :w-WE - | 3 | highway | residential | foo | :w-NS - When importing - Then table placex contains - | object | parent_place_id | street | addr_place | housenumber - | W1 | N4 | None | theplace | 3 - | N1 | W2 | bar | None | None - | N2 | W3 | None | None | 4 - | N3 | W2 | None | nowhere | None - - ### Scenario 20 - Scenario: POIs parent a road if they are attached to it - Given the scene points-on-roads - And the named place nodes - | osm_id | class | type | street | geometry - | 1 | highway | bus_stop | North St | :n-SE - | 2 | highway | bus_stop | South St | :n-NW - | 3 | highway | bus_stop | North St | :n-S-unglued - | 4 | highway | bus_stop | South St | :n-N-unglued - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | secondary | North St | :w-north - | 2 | highway | unclassified | South St | :w-south - And the ways - | id | nodes - | 1 | 100,101,2,103,104 - | 2 | 200,201,1,202,203 - When importing - Then table placex contains - | object | parent_place_id - | N1 | W1 - | N2 | W2 - | N3 | W1 - | N4 | W2 - - Scenario: POIs do not parent non-roads they are attached to - Given the scene points-on-roads - And the named place nodes - | osm_id | class | type | street | geometry - | 1 | highway | bus_stop | North St | :n-SE - | 2 | highway | bus_stop | South St | :n-NW - And the place ways - | osm_id | class | type | name | geometry - | 1 | landuse | residential | North St | :w-north - | 2 | waterway| river | South St | :w-south - And the ways - | id | nodes - | 1 | 100,101,2,103,104 - | 2 | 200,201,1,202,203 - When importing - Then table placex contains - | object | parent_place_id - | N1 | 0 - | N2 | 0 - - Scenario: POIs on building outlines inherit associated street relation - Given the scene building-on-street-corner - And the named place nodes - | osm_id | class | type | geometry - | 1 | place | house | :n-edge-NS - And the named place ways - | osm_id | class | type | geometry - | 1 | building | yes | :w-building - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | primary | bar | :w-WE - | 3 | highway | residential | foo | :w-NS - And the relations - | id | members | tags - | 1 | W1:house,W2:street | 'type' : 'associatedStreet' - And the ways - | id | nodes - | 1 | 100,1,101,102,100 - When importing - Then table placex contains - | object | parent_place_id - | N1 | W2 - diff --git a/tests/features/db/import/placex.feature b/tests/features/db/import/placex.feature deleted file mode 100644 index 95e0bc91..00000000 --- a/tests/features/db/import/placex.feature +++ /dev/null @@ -1,318 +0,0 @@ -@DB -Feature: Import into placex - Tests that data in placex is completed correctly. - - Scenario: No country code tag is available - Given the place nodes - | osm_id | class | type | name | geometry - | 1 | highway | primary | 'name' : 'A1' | country:us - When importing - Then table placex contains - | object | country_code | calculated_country_code | - | N1 | None | us | - - Scenario: Location overwrites country code tag - Given the scene country - And the place nodes - | osm_id | class | type | name | country_code | geometry - | 1 | highway | primary | 'name' : 'A1' | de | :us - When importing - Then table placex contains - | object | country_code | calculated_country_code | - | N1 | de | us | - - Scenario: Country code tag overwrites location for countries - Given the place areas - | osm_type | osm_id | class | type | admin_level | name | country_code | geometry - | R | 1 | boundary | administrative | 2 | 'name' : 'foo' | de | (-100 40, -101 40, -101 41, -100 41, -100 40) - When importing - Then table placex contains - | object | country_code | calculated_country_code | - | R1 | de | de | - - Scenario: Illegal country code tag for countries is ignored - And the place areas - | osm_type | osm_id | class | type | admin_level | name | country_code | geometry - | R | 1 | boundary | administrative | 2 | 'name' : 'foo' | xx | (-100 40, -101 40, -101 41, -100 41, -100 40) - When importing - Then table placex contains - | object | country_code | calculated_country_code | - | R1 | xx | us | - - Scenario: admin level is copied over - Given the place nodes - | osm_id | class | type | admin_level | name - | 1 | place | state | 3 | 'name' : 'foo' - When importing - Then table placex contains - | object | admin_level | - | N1 | 3 | - - Scenario: admin level is default 15 - Given the place nodes - | osm_id | class | type | name - | 1 | amenity | prison | 'name' : 'foo' - When importing - Then table placex contains - | object | admin_level | - | N1 | 15 | - - Scenario: admin level is never larger than 15 - Given the place nodes - | osm_id | class | type | name | admin_level - | 1 | amenity | prison | 'name' : 'foo' | 16 - When importing - Then table placex contains - | object | admin_level | - | N1 | 15 | - - - Scenario: postcode node without postcode is dropped - Given the place nodes - | osm_id | class | type - | 1 | place | postcode - When importing - Then table placex has no entry for N1 - - Scenario: postcode boundary without postcode is dropped - Given the place areas - | osm_type | osm_id | class | type | geometry - | R | 1 | boundary | postal_code | poly-area:0.1 - When importing - Then table placex has no entry for R1 - - Scenario: search and address ranks for GB post codes correctly assigned - Given the place nodes - | osm_id | class | type | postcode | geometry - | 1 | place | postcode | E45 2CD | country:gb - | 2 | place | postcode | E45 2 | country:gb - | 3 | place | postcode | Y45 | country:gb - When importing - Then table placex contains - | object | postcode | calculated_country_code | rank_search | rank_address - | N1 | E45 2CD | gb | 25 | 5 - | N2 | E45 2 | gb | 23 | 5 - | N3 | Y45 | gb | 21 | 5 - - Scenario: wrongly formatted GB postcodes are down-ranked - Given the place nodes - | osm_id | class | type | postcode | geometry - | 1 | place | postcode | EA452CD | country:gb - | 2 | place | postcode | E45 23 | country:gb - | 3 | place | postcode | y45 | country:gb - When importing - Then table placex contains - | object | calculated_country_code | rank_search | rank_address - | N1 | gb | 30 | 30 - | N2 | gb | 30 | 30 - | N3 | gb | 30 | 30 - - Scenario: search and address rank for DE postcodes correctly assigned - Given the place nodes - | osm_id | class | type | postcode | geometry - | 1 | place | postcode | 56427 | country:de - | 2 | place | postcode | 5642 | country:de - | 3 | place | postcode | 5642A | country:de - | 4 | place | postcode | 564276 | country:de - When importing - Then table placex contains - | object | calculated_country_code | rank_search | rank_address - | N1 | de | 21 | 11 - | N2 | de | 30 | 30 - | N3 | de | 30 | 30 - | N4 | de | 30 | 30 - - Scenario: search and address rank for other postcodes are correctly assigned - Given the place nodes - | osm_id | class | type | postcode | geometry - | 1 | place | postcode | 1 | country:ca - | 2 | place | postcode | X3 | country:ca - | 3 | place | postcode | 543 | country:ca - | 4 | place | postcode | 54dc | country:ca - | 5 | place | postcode | 12345 | country:ca - | 6 | place | postcode | 55TT667 | country:ca - | 7 | place | postcode | 123-65 | country:ca - | 8 | place | postcode | 12 445 4 | country:ca - | 9 | place | postcode | A1:bc10 | country:ca - When importing - Then table placex contains - | object | calculated_country_code | rank_search | rank_address - | N1 | ca | 21 | 11 - | N2 | ca | 21 | 11 - | N3 | ca | 21 | 11 - | N4 | ca | 21 | 11 - | N5 | ca | 21 | 11 - | N6 | ca | 21 | 11 - | N7 | ca | 25 | 11 - | N8 | ca | 25 | 11 - | N9 | ca | 25 | 11 - - - Scenario: search and address ranks for places are correctly assigned - Given the named place nodes - | osm_id | class | type | - | 1 | foo | bar | - | 11 | place | Continent | - | 12 | place | continent | - | 13 | place | sea | - | 14 | place | country | - | 15 | place | state | - | 16 | place | region | - | 17 | place | county | - | 18 | place | city | - | 19 | place | island | - | 20 | place | town | - | 21 | place | village | - | 22 | place | hamlet | - | 23 | place | municipality | - | 24 | place | district | - | 25 | place | unincorporated_area | - | 26 | place | borough | - | 27 | place | suburb | - | 28 | place | croft | - | 29 | place | subdivision | - | 30 | place | isolated_dwelling | - | 31 | place | farm | - | 32 | place | locality | - | 33 | place | islet | - | 34 | place | mountain_pass | - | 35 | place | neighbourhood | - | 36 | place | house | - | 37 | place | building | - | 38 | place | houses | - And the named place nodes - | osm_id | class | type | extratags - | 100 | place | locality | 'locality' : 'townland' - | 101 | place | city | 'capital' : 'yes' - When importing - Then table placex contains - | object | rank_search | rank_address | - | N1 | 30 | 30 | - | N11 | 30 | 30 | - | N12 | 2 | 2 | - | N13 | 2 | 0 | - | N14 | 4 | 4 | - | N15 | 8 | 8 | - | N16 | 18 | 0 | - | N17 | 12 | 12 | - | N18 | 16 | 16 | - | N19 | 17 | 0 | - | N20 | 18 | 16 | - | N21 | 19 | 16 | - | N22 | 19 | 16 | - | N23 | 19 | 16 | - | N24 | 19 | 16 | - | N25 | 19 | 16 | - | N26 | 19 | 16 | - | N27 | 20 | 20 | - | N28 | 20 | 20 | - | N29 | 20 | 20 | - | N30 | 20 | 20 | - | N31 | 20 | 0 | - | N32 | 20 | 0 | - | N33 | 20 | 0 | - | N34 | 20 | 0 | - | N100 | 20 | 20 | - | N101 | 15 | 16 | - | N35 | 22 | 22 | - | N36 | 30 | 30 | - | N37 | 30 | 30 | - | N38 | 28 | 0 | - - Scenario: search and address ranks for boundaries are correctly assigned - Given the named place nodes - | osm_id | class | type - | 1 | boundary | administrative - And the named place ways - | osm_id | class | type | geometry - | 10 | boundary | administrative | 10 10, 11 11 - And the named place areas - | osm_type | osm_id | class | type | admin_level | geometry - | R | 20 | boundary | administrative | 2 | (1 1, 2 2, 1 2, 1 1) - | R | 21 | boundary | administrative | 32 | (3 3, 4 4, 3 4, 3 3) - | R | 22 | boundary | nature_park | 6 | (0 0, 1 0, 0 1, 0 0) - | R | 23 | boundary | natural_reserve| 10 | (0 0, 1 1, 1 0, 0 0) - When importing - Then table placex has no entry for N1 - And table placex has no entry for W10 - And table placex contains - | object | rank_search | rank_address - | R20 | 4 | 4 - | R21 | 30 | 30 - | R22 | 12 | 0 - | R23 | 20 | 0 - - Scenario: search and address ranks for highways correctly assigned - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type - | 1 | highway | bus_stop - And the place ways - | osm_id | class | type | geometry - | 1 | highway | primary | :w-south - | 2 | highway | secondary | :w-south - | 3 | highway | tertiary | :w-south - | 4 | highway | residential | :w-north - | 5 | highway | unclassified | :w-north - | 6 | highway | something | :w-north - When importing - Then table placex contains - | object | rank_search | rank_address - | N1 | 30 | 30 - | W1 | 26 | 26 - | W2 | 26 | 26 - | W3 | 26 | 26 - | W4 | 26 | 26 - | W5 | 26 | 26 - | W6 | 26 | 26 - - Scenario: rank and inclusion of landuses - And the named place nodes - | osm_id | class | type - | 2 | landuse | residential - And the named place ways - | osm_id | class | type | geometry - | 2 | landuse | residential | 1 1, 1 1.1 - And the named place areas - | osm_type | osm_id | class | type | geometry - | W | 4 | landuse | residential | poly-area:0.1 - | R | 2 | landuse | residential | poly-area:0.05 - | R | 3 | landuse | forrest | poly-area:0.5 - When importing - And table placex contains - | object | rank_search | rank_address - | N2 | 30 | 30 - | W2 | 30 | 30 - | W4 | 22 | 22 - | R2 | 22 | 22 - | R3 | 22 | 0 - - Scenario: rank and inclusion of naturals - And the named place nodes - | osm_id | class | type - | 2 | natural | peak - | 4 | natural | volcano - | 5 | natural | foobar - And the named place ways - | osm_id | class | type | geometry - | 2 | natural | mountain_range | 12 12,11 11 - | 3 | natural | foobar | 13 13,13.1 13 - And the named place areas - | osm_type | osm_id | class | type | geometry - | R | 3 | natural | volcano | poly-area:0.1 - | R | 4 | natural | foobar | poly-area:0.5 - | R | 5 | natural | sea | poly-area:5.0 - | R | 6 | natural | sea | poly-area:0.01 - When importing - And table placex contains - | object | rank_search | rank_address - | N2 | 18 | 0 - | N4 | 18 | 0 - | N5 | 30 | 30 - | W2 | 18 | 0 - | R3 | 18 | 0 - | R4 | 22 | 0 - | R5 | 4 | 4 - | R6 | 4 | 4 - | W3 | 30 | 30 - diff --git a/tests/features/db/import/search_terms.feature b/tests/features/db/import/search_terms.feature deleted file mode 100644 index f68fe61c..00000000 --- a/tests/features/db/import/search_terms.feature +++ /dev/null @@ -1,42 +0,0 @@ -@DB -Feature: Creation of search terms - Tests that search_name table is filled correctly - - Scenario: POIs without a name have no search entry - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | geometry - | 1 | place | house | :p-N1 - And the place ways - | osm_id | class | type | geometry - | 1 | highway | residential | :w-north - When importing - Then table search_name has no entry for N1 - - - Scenario: Named POIs inherit address from parent - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | name | geometry - | 1 | place | house | foo | :p-N1 - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | the road | :w-north - When importing - Then search_name table contains - | place_id | name_vector | nameaddress_vector - | N1 | foo | the road - - Scenario: Roads take over the postcode from attached houses - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | housenumber | postcode | street | geometry - | 1 | place | house | 1 | 12345 | North St |:p-S1 - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | North St | :w-north - When importing - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 - diff --git a/tests/features/db/import/simple.feature b/tests/features/db/import/simple.feature deleted file mode 100644 index 2e2c825a..00000000 --- a/tests/features/db/import/simple.feature +++ /dev/null @@ -1,17 +0,0 @@ -@DB -Feature: Import of simple objects - Testing simple stuff - - Scenario: Import place node - Given the place nodes: - | osm_id | class | type | name | geometry - | 1 | place | village | 'name' : 'Foo' | 10.0 -10.0 - When importing - Then table placex contains - | object | class | type | name | centroid - | N1 | place | village | 'name' : 'Foo' | 10.0,-10.0 +- 1m - When sending query "Foo" - Then results contain - | ID | osm_type | osm_id - | 0 | N | 1 - diff --git a/tests/features/db/update/interpolation.feature b/tests/features/db/update/interpolation.feature deleted file mode 100644 index 66367b10..00000000 --- a/tests/features/db/update/interpolation.feature +++ /dev/null @@ -1,258 +0,0 @@ -@DB -Feature: Update of address interpolations - Test the interpolated address are updated correctly - - Scenario: addr:street added to interpolation - Given the scene parallel-road - And the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | :n-middle-w - | 2 | place | house | 6 | :n-middle-e - And the place ways - | osm_id | class | type | housenumber | geometry - | 10 | place | houses | even | :w-middle - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | unclassified | 'name' : 'Sun Way' | :w-north - | 3 | highway | unclassified | 'name' : 'Cloud Street' | :w-south - And the ways - | id | nodes - | 10 | 1,100,101,102,2 - When importing - Then table placex contains - | object | parent_place_id - | N1 | W2 - | N2 | W2 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W10 | W2 | 2 | 6 - When updating place ways - | osm_id | class | type | housenumber | street | geometry - | 10 | place | houses | even | Cloud Street | :w-middle - Then table placex contains - | object | parent_place_id - | N1 | W3 - | N2 | W3 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W10 | W3 | 2 | 6 - - Scenario: addr:street added to housenumbers - Given the scene parallel-road - And the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | :n-middle-w - | 2 | place | house | 6 | :n-middle-e - And the place ways - | osm_id | class | type | housenumber | geometry - | 10 | place | houses | even | :w-middle - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | unclassified | 'name' : 'Sun Way' | :w-north - | 3 | highway | unclassified | 'name' : 'Cloud Street' | :w-south - And the ways - | id | nodes - | 10 | 1,100,101,102,2 - When importing - Then table placex contains - | object | parent_place_id - | N1 | W2 - | N2 | W2 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W10 | W2 | 2 | 6 - When updating place nodes - | osm_id | class | type | street | housenumber | geometry - | 1 | place | house | Cloud Street| 2 | :n-middle-w - | 2 | place | house | Cloud Street| 6 | :n-middle-e - Then table placex contains - | object | parent_place_id - | N1 | W3 - | N2 | W3 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W10 | W3 | 2 | 6 - - - Scenario: interpolation tag removed - Given the scene parallel-road - And the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | :n-middle-w - | 2 | place | house | 6 | :n-middle-e - And the place ways - | osm_id | class | type | housenumber | geometry - | 10 | place | houses | even | :w-middle - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | unclassified | 'name' : 'Sun Way' | :w-north - | 3 | highway | unclassified | 'name' : 'Cloud Street' | :w-south - And the ways - | id | nodes - | 10 | 1,100,101,102,2 - When importing - Then table placex contains - | object | parent_place_id - | N1 | W2 - | N2 | W2 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W10 | W2 | 2 | 6 - When marking for delete W10 - Then table location_property_osmline has no entry for W10 - And table placex contains - | object | parent_place_id - | N1 | W2 - | N2 | W2 - - - Scenario: referenced road added - Given the scene parallel-road - And the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | :n-middle-w - | 2 | place | house | 6 | :n-middle-e - And the place ways - | osm_id | class | type | housenumber | street | geometry - | 10 | place | houses | even | Cloud Street| :w-middle - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | unclassified | 'name' : 'Sun Way' | :w-north - And the ways - | id | nodes - | 10 | 1,100,101,102,2 - When importing - Then table placex contains - | object | parent_place_id - | N1 | W2 - | N2 | W2 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W10 | W2 | 2 | 6 - When updating place ways - | osm_id | class | type | name | geometry - | 3 | highway | unclassified | 'name' : 'Cloud Street' | :w-south - Then table placex contains - | object | parent_place_id - | N1 | W3 - | N2 | W3 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W10 | W3 | 2 | 6 - - - Scenario: referenced road deleted - Given the scene parallel-road - And the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | :n-middle-w - | 2 | place | house | 6 | :n-middle-e - And the place ways - | osm_id | class | type | housenumber | street | geometry - | 10 | place | houses | even | Cloud Street| :w-middle - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | unclassified | 'name' : 'Sun Way' | :w-north - | 3 | highway | unclassified | 'name' : 'Cloud Street' | :w-south - And the ways - | id | nodes - | 10 | 1,100,101,102,2 - When importing - Then table placex contains - | object | parent_place_id - | N1 | W3 - | N2 | W3 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W10 | W3 | 2 | 6 - When marking for delete W3 - Then table placex contains - | object | parent_place_id - | N1 | W2 - | N2 | W2 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W10 | W2 | 2 | 6 - - Scenario: building becomes interpolation - Given the scene building-with-parallel-streets - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 3 | :w-building - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | unclassified | 'name' : 'Cloud Street' | :w-south - When importing - Then table placex contains - | object | parent_place_id - | W1 | W2 - When updating place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | :n-north-w - | 2 | place | house | 6 | :n-north-e - And the ways - | id | nodes - | 1 | 1,100,101,102,2 - And updating place ways - | osm_id | class | type | housenumber | street | geometry - | 1 | place | houses | even | Cloud Street| :w-north - Then table placex has no entry for W1 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W1 | W2 | 2 | 6 - - - - Scenario: interpolation becomes building - Given the scene building-with-parallel-streets - And the place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | :n-north-w - | 2 | place | house | 6 | :n-north-e - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | unclassified | 'name' : 'Cloud Street' | :w-south - And the ways - | id | nodes - | 1 | 1,100,101,102,2 - And the place ways - | osm_id | class | type | housenumber | street | geometry - | 1 | place | houses | even | Cloud Street| :w-north - When importing - Then table placex has no entry for W1 - And table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W1 | W2 | 2 | 6 - When updating place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 3 | :w-building - Then table placex contains - | object | parent_place_id - | W1 | W2 - - Scenario: housenumbers added to interpolation - Given the scene building-with-parallel-streets - And the place ways - | osm_id | class | type | name | geometry - | 2 | highway | unclassified | 'name' : 'Cloud Street' | :w-south - And the ways - | id | nodes - | 1 | 1,100,101,102,2 - And the place ways - | osm_id | class | type | housenumber | geometry - | 1 | place | houses | even | :w-north - When importing - Then table location_property_osmline has no entry for W1 - When updating place nodes - | osm_id | class | type | housenumber | geometry - | 1 | place | house | 2 | :n-north-w - | 2 | place | house | 6 | :n-north-e - And updating place ways - | osm_id | class | type | housenumber | street | geometry - | 1 | place | houses | even | Cloud Street| :w-north - Then table location_property_osmline contains - | object | parent_place_id | startnumber | endnumber - | W1 | W2 | 2 | 6 - - - diff --git a/tests/features/db/update/linked_places.feature b/tests/features/db/update/linked_places.feature deleted file mode 100644 index 777a02f0..00000000 --- a/tests/features/db/update/linked_places.feature +++ /dev/null @@ -1,96 +0,0 @@ -@DB -Feature: Updates of linked places - Tests that linked places are correctly added and deleted. - - - Scenario: Add linked place when linking relation is renamed - Given the place nodes - | osm_id | class | type | name | geometry - | 1 | place | city | foo | 0 0 - And the place areas - | osm_type | osm_id | class | type | name | admin_level | geometry - | R | 1 | boundary | administrative | foo | 8 | poly-area:0.1 - When importing - And sending query "foo" with dups - Then results contain - | osm_type - | R - When updating place areas - | osm_type | osm_id | class | type | name | admin_level | geometry - | R | 1 | boundary | administrative | foobar | 8 | poly-area:0.1 - Then table placex contains - | object | linked_place_id - | N1 | None - When updating place areas - | osm_type | osm_id | class | type | name | admin_level | geometry - When sending query "foo" with dups - Then results contain - | osm_type - | N - - Scenario: Add linked place when linking relation is removed - Given the place nodes - | osm_id | class | type | name | geometry - | 1 | place | city | foo | 0 0 - And the place areas - | osm_type | osm_id | class | type | name | admin_level | geometry - | R | 1 | boundary | administrative | foo | 8 | poly-area:0.1 - When importing - And sending query "foo" with dups - Then results contain - | osm_type - | R - When marking for delete R1 - Then table placex contains - | object | linked_place_id - | N1 | None - When updating place areas - | osm_type | osm_id | class | type | name | admin_level | geometry - And sending query "foo" with dups - Then results contain - | osm_type - | N - - Scenario: Remove linked place when linking relation is added - Given the place nodes - | osm_id | class | type | name | geometry - | 1 | place | city | foo | 0 0 - When importing - And sending query "foo" with dups - Then results contain - | osm_type - | N - When updating place areas - | osm_type | osm_id | class | type | name | admin_level | geometry - | R | 1 | boundary | administrative | foo | 8 | poly-area:0.1 - Then table placex contains - | object | linked_place_id - | N1 | R1 - When sending query "foo" with dups - Then results contain - | osm_type - | R - - Scenario: Remove linked place when linking relation is renamed - Given the place nodes - | osm_id | class | type | name | geometry - | 1 | place | city | foo | 0 0 - And the place areas - | osm_type | osm_id | class | type | name | admin_level | geometry - | R | 1 | boundary | administrative | foobar | 8 | poly-area:0.1 - When importing - And sending query "foo" with dups - Then results contain - | osm_type - | N - When updating place areas - | osm_type | osm_id | class | type | name | admin_level | geometry - | R | 1 | boundary | administrative | foo | 8 | poly-area:0.1 - Then table placex contains - | object | linked_place_id - | N1 | R1 - When sending query "foo" with dups - Then results contain - | osm_type - | R - diff --git a/tests/features/db/update/naming.feature b/tests/features/db/update/naming.feature deleted file mode 100644 index 261f02dc..00000000 --- a/tests/features/db/update/naming.feature +++ /dev/null @@ -1,39 +0,0 @@ -@DB -Feature: Update of names in place objects - Test all naming related issues in updates - - - Scenario: Updating postcode in postcode boundaries without ref - Given the place areas - | osm_type | osm_id | class | type | postcode | geometry - | R | 1 | boundary | postal_code | 12345 | (0 0, 1 0, 1 1, 0 1, 0 0) - When importing - And sending query "12345" - Then results contain - | ID | osm_type | osm_id - | 0 | R | 1 - When updating place areas - | osm_type | osm_id | class | type | postcode | geometry - | R | 1 | boundary | postal_code | 54321 | (0 0, 1 0, 1 1, 0 1, 0 0) - And sending query "12345" - Then exactly 0 results are returned - When sending query "54321" - Then results contain - | ID | osm_type | osm_id - | 0 | R | 1 - - - Scenario: Delete postcode from postcode boundaries without ref - Given the place areas - | osm_type | osm_id | class | type | postcode | geometry - | R | 1 | boundary | postal_code | 12345 | (0 0, 1 0, 1 1, 0 1, 0 0) - When importing - And sending query "12345" - Then results contain - | ID | osm_type | osm_id - | 0 | R | 1 - When updating place areas - | osm_type | osm_id | class | type | geometry - | R | 1 | boundary | postal_code | (0 0, 1 0, 1 1, 0 1, 0 0) - Then table placex has no entry for R1 - diff --git a/tests/features/db/update/search_terms.feature b/tests/features/db/update/search_terms.feature deleted file mode 100644 index d8c4440a..00000000 --- a/tests/features/db/update/search_terms.feature +++ /dev/null @@ -1,117 +0,0 @@ -@DB -Feature: Update of search terms - Tests that search_name table is filled correctly - - Scenario: POI-inherited postcode remains when way type is changed - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | housenumber | postcode | street | geometry - | 1 | place | house | 1 | 12345 | North St |:p-S1 - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | North St | :w-north - When importing - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 - When updating place ways - | osm_id | class | type | name | geometry - | 1 | highway | unclassified | North St | :w-north - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 - - Scenario: POI-inherited postcode remains when way name is changed - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | housenumber | postcode | street | geometry - | 1 | place | house | 1 | 12345 | North St |:p-S1 - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | North St | :w-north - When importing - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 - When updating place ways - | osm_id | class | type | name | geometry - | 1 | highway | unclassified | South St | :w-north - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 - - Scenario: POI-inherited postcode remains when way geometry is changed - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | housenumber | postcode | street | geometry - | 1 | place | house | 1 | 12345 | North St |:p-S1 - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | North St | :w-north - When importing - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 - When updating place ways - | osm_id | class | type | name | geometry - | 1 | highway | unclassified | South St | :w-south - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 - - Scenario: POI-inherited postcode is added when POI postcode changes - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | housenumber | postcode | street | geometry - | 1 | place | house | 1 | 12345 | North St |:p-S1 - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | North St | :w-north - When importing - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 - When updating place nodes - | osm_id | class | type | housenumber | postcode | street | geometry - | 1 | place | house | 1 | 54321 | North St |:p-S1 - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 54321 - - Scenario: POI-inherited postcode remains when POI geometry changes - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | housenumber | postcode | street | geometry - | 1 | place | house | 1 | 12345 | North St |:p-S1 - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | North St | :w-north - When importing - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 - When updating place nodes - | osm_id | class | type | housenumber | postcode | street | geometry - | 1 | place | house | 1 | 12345 | North St |:p-S2 - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 - - - Scenario: POI-inherited postcode remains when another POI is deleted - Given the scene roads-with-pois - And the place nodes - | osm_id | class | type | housenumber | postcode | street | geometry - | 1 | place | house | 1 | 12345 | North St |:p-S1 - | 2 | place | house | 2 | | North St |:p-S2 - And the place ways - | osm_id | class | type | name | geometry - | 1 | highway | residential | North St | :w-north - When importing - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 - When marking for delete N2 - Then search_name table contains - | place_id | nameaddress_vector - | W1 | 12345 diff --git a/tests/features/db/update/simple.feature b/tests/features/db/update/simple.feature deleted file mode 100644 index 517e7bde..00000000 --- a/tests/features/db/update/simple.feature +++ /dev/null @@ -1,73 +0,0 @@ -@DB -Feature: Update of simple objects - Testing simple stuff - - Scenario: Do delete small boundary features - Given the place areas - | osm_type | osm_id | class | type | admin_level | geometry - | R | 1 | boundary | administrative | 3 | (0 0, 1 0, 1 1, 0 1, 0 0) - When importing - Then table placex contains - | object | rank_search - | R1 | 6 - When marking for delete R1 - Then table placex has no entry for R1 - - Scenario: Do not delete large boundary features - Given the place areas - | osm_type | osm_id | class | type | admin_level | geometry - | R | 1 | boundary | administrative | 3 | (0 0, 2 0, 2 2.1, 0 2, 0 0) - When importing - Then table placex contains - | object | rank_search - | R1 | 6 - When marking for delete R1 - Then table placex contains - | object | rank_search - | R1 | 6 - - Scenario: Do delete large features of low rank - Given the named place areas - | osm_type | osm_id | class | type | geometry - | W | 1 | place | house | (0 0, 2 0, 2 2.1, 0 2, 0 0) - | R | 1 | boundary | national_park | (0 0, 2 0, 2 2.1, 0 2, 0 0) - When importing - Then table placex contains - | object | rank_address - | R1 | 0 - | W1 | 30 - When marking for delete R1,W1 - Then table placex has no entry for W1 - Then table placex has no entry for R1 - - - Scenario: type mutation - Given the place nodes - | osm_id | class | type | geometry - | 3 | shop | toys | 1 -1 - When importing - Then table placex contains - | object | class | type - | N3 | shop | toys - When updating place nodes - | osm_id | class | type | geometry - | 3 | shop | grocery | 1 -1 - Then table placex contains - | object | class | type - | N3 | shop | grocery - - - Scenario: remove postcode place when house number is added - Given the place nodes - | osm_id | class | type | postcode | geometry - | 3 | place | postcode | 12345 | 1 -1 - When importing - Then table placex contains - | object | class | type - | N3 | place | postcode - When updating place nodes - | osm_id | class | type | postcode | housenumber | geometry - | 3 | place | house | 12345 | 13 | 1 -1 - Then table placex contains - | object | class | type - | N3 | place | house diff --git a/tests/features/osm2pgsql/import/broken.feature b/tests/features/osm2pgsql/import/broken.feature deleted file mode 100644 index 58a45f91..00000000 --- a/tests/features/osm2pgsql/import/broken.feature +++ /dev/null @@ -1,37 +0,0 @@ -@DB -Feature: Import of objects with broken geometries by osm2pgsql - - @Fail - Scenario: Import way with double nodes - Given the osm nodes: - | id | geometry - | 100 | 0 0 - | 101 | 0 0.1 - | 102 | 0.1 0.2 - And the osm ways: - | id | tags | nodes - | 1 | 'highway' : 'primary' | 100 101 101 102 - When loading osm data - Then table place contains - | object | class | type | geometry - | W1 | highway | primary | (0 0, 0 0.1, 0.1 0.2) - - Scenario: Import of ballon areas - Given the osm nodes: - | id | geometry - | 1 | 0 0 - | 2 | 0 0.0001 - | 3 | 0.00001 0.0001 - | 4 | 0.00001 0 - | 5 | -0.00001 0 - And the osm ways: - | id | tags | nodes - | 1 | 'highway' : 'unclassified' | 1 2 3 4 1 5 - | 2 | 'highway' : 'unclassified' | 1 2 3 4 1 - | 3 | 'highway' : 'unclassified' | 1 2 3 4 3 - When loading osm data - Then table place contains - | object | geometrytype - | W1 | ST_LineString - | W2 | ST_Polygon - | W3 | ST_LineString diff --git a/tests/features/osm2pgsql/import/relation.feature b/tests/features/osm2pgsql/import/relation.feature deleted file mode 100644 index aba99a47..00000000 --- a/tests/features/osm2pgsql/import/relation.feature +++ /dev/null @@ -1,13 +0,0 @@ -@DB -Feature: Import of relations by osm2pgsql - Testing specific relation problems related to members. - - Scenario: Don't import empty waterways - Given the osm nodes: - | id | tags - | 1 | 'amenity' : 'prison', 'name' : 'foo' - And the osm relations: - | id | tags | members - | 1 | 'type' : 'waterway', 'waterway' : 'river', 'name' : 'XZ' | N1 - When loading osm data - Then table place has no entry for R1 diff --git a/tests/features/osm2pgsql/import/simple.feature b/tests/features/osm2pgsql/import/simple.feature deleted file mode 100644 index 447dab0d..00000000 --- a/tests/features/osm2pgsql/import/simple.feature +++ /dev/null @@ -1,68 +0,0 @@ -@DB -Feature: Import of simple objects by osm2pgsql - Testing basic tagging in osm2pgsql imports. - - Scenario: Import simple objects - Given the osm nodes: - | id | tags - | 1 | 'amenity' : 'prison', 'name' : 'foo' - Given the osm nodes: - | id | geometry - | 100 | 0 0 - | 101 | 0 0.1 - | 102 | 0.1 0.2 - | 200 | 0 0 - | 201 | 0 1 - | 202 | 1 1 - | 203 | 1 0 - And the osm ways: - | id | tags | nodes - | 1 | 'shop' : 'toys', 'name' : 'tata' | 100 101 102 - | 2 | 'ref' : '45' | 200 201 202 203 200 - And the osm relations: - | id | tags | members - | 1 | 'type' : 'multipolygon', 'tourism' : 'hotel', 'name' : 'XZ' | N1,W2 - When loading osm data - Then table place contains - | object | class | type | name - | N1 | amenity | prison | 'name' : 'foo' - | W1 | shop | toys | 'name' : 'tata' - | R1 | tourism | hotel | 'name' : 'XZ' - - Scenario: Import object with two main tags - Given the osm nodes: - | id | tags - | 1 | 'tourism' : 'hotel', 'amenity' : 'restaurant', 'name' : 'foo' - When loading osm data - Then table place contains - | object | class | type | name - | N1:tourism | tourism | hotel | 'name' : 'foo' - | N1:amenity | amenity | restaurant | 'name' : 'foo' - - Scenario: Import stand-alone house number with postcode - Given the osm nodes: - | id | tags - | 1 | 'addr:housenumber' : '4', 'addr:postcode' : '3345' - When loading osm data - Then table place contains - | object | class | type - | N1 | place | house - - Scenario: Landuses are only imported when named - Given the osm nodes: - | id | geometry - | 100 | 0 0 - | 101 | 0 0.1 - | 102 | 0.1 0.1 - | 200 | 0 0 - | 202 | 1 1 - | 203 | 1 0 - And the osm ways: - | id | tags | nodes - | 1 | 'landuse' : 'residential', 'name' : 'rainbow' | 100 101 102 100 - | 2 | 'landuse' : 'residential' | 200 202 203 200 - When loading osm data - Then table place contains - | object | class | type - | W1 | landuse | residential - And table place has no entry for W2 diff --git a/tests/features/osm2pgsql/import/tags.feature b/tests/features/osm2pgsql/import/tags.feature deleted file mode 100644 index 3e3d853b..00000000 --- a/tests/features/osm2pgsql/import/tags.feature +++ /dev/null @@ -1,550 +0,0 @@ -@DB -Feature: Tag evaluation - Tests if tags are correctly imported into the place table - - Scenario Outline: Name tags - Given the osm nodes: - | id | tags - | 1 | 'highway' : 'yes', '' : 'Foo' - When loading osm data - Then table place contains - | object | name - | N1 | '' : 'Foo' - - Examples: - | nametag - | ref - | int_ref - | nat_ref - | reg_ref - | loc_ref - | old_ref - | iata - | icao - | pcode:1 - | pcode:2 - | pcode:3 - | name - | name:de - | name:bt-BR - | int_name - | int_name:xxx - | nat_name - | nat_name:fr - | reg_name - | reg_name:1 - | loc_name - | loc_name:DE - | old_name - | old_name:v1 - | alt_name - | alt_name:dfe - | alt_name_1 - | official_name - | short_name - | short_name:CH - | addr:housename - | brand - - Scenario Outline: operator only for shops and amenities - Given the osm nodes: - | id | tags - | 1 | 'highway' : 'yes', 'operator' : 'Foo', 'name' : 'null' - | 2 | 'shop' : 'grocery', 'operator' : 'Foo' - | 3 | 'amenity' : 'hospital', 'operator' : 'Foo' - | 4 | 'tourism' : 'hotel', 'operator' : 'Foo' - When loading osm data - Then table place contains - | object | name - | N1 | 'name' : 'null' - | N2 | 'operator' : 'Foo' - | N3 | 'operator' : 'Foo' - | N4 | 'operator' : 'Foo' - - Scenario Outline: Ignored name tags - Given the osm nodes: - | id | tags - | 1 | 'highway' : 'yes', '' : 'Foo', 'name' : 'real' - When loading osm data - Then table place contains - | object | name - | N1 | 'name' : 'real' - - Examples: - | nametag - | name_de - | Name - | ref:de - | ref_de - | my:ref - | br:name - | name:prefix - | name:source - - Scenario: Special character in name tag - Given the osm nodes: - | id | tags - | 1 | 'highway' : 'yes', 'name: de' : 'Foo', 'name' : 'real1' - | 2 | 'highway' : 'yes', 'name: de' : 'Foo', 'name' : 'real2' - | 3 | 'highway' : 'yes', 'name: de' : 'Foo', 'name:\\' : 'real3' - When loading osm data - Then table place contains - | object | name - | N1 | 'name: de' : 'Foo', 'name' : 'real1' - | N2 | 'name: de' : 'Foo', 'name' : 'real2' - | N3 | 'name: de' : 'Foo', 'name:\\' : 'real3' - - Scenario Outline: Included places - Given the osm nodes: - | id | tags - | 1 | '' : '', 'name' : 'real' - When loading osm data - Then table place contains - | object | name - | N1 | 'name' : 'real' - - Examples: - | key | value - | emergency | phone - | tourism | information - | historic | castle - | military | barracks - | natural | water - | highway | residential - | aerialway | station - | aeroway | way - | boundary | administrative - | craft | butcher - | leisure | playground - | office | bookmaker - | railway | rail - | shop | bookshop - | waterway | stream - | landuse | cemetry - | man_made | tower - | mountain_pass | yes - - Scenario Outline: Bridges and Tunnels take special name tags - Given the osm nodes: - | id | tags - | 1 | 'highway' : 'road', '' : 'yes', 'name' : 'Rd', ':name' : 'My' - | 2 | 'highway' : 'road', '' : 'yes', 'name' : 'Rd' - When loading osm data - Then table place contains - | object | class | type | name - | N1:highway | highway | road | 'name' : 'Rd' - | N1: | | yes | 'name' : 'My' - | N2:highway | highway | road | 'name' : 'Rd' - And table place has no entry for N2: - - Examples: - | key - | bridge - | tunnel - - Scenario Outline: Excluded places - Given the osm nodes: - | id | tags - | 1 | '' : '', 'name' : 'real' - | 2 | 'highway' : 'motorway', 'name' : 'To Hell' - When loading osm data - Then table place has no entry for N1 - - Examples: - | key | value - | emergency | yes - | emergency | no - | tourism | yes - | tourism | no - | historic | yes - | historic | no - | military | yes - | military | no - | natural | yes - | natural | no - | highway | no - | highway | turning_circle - | highway | mini_roundabout - | highway | noexit - | highway | crossing - | aerialway | no - | aerialway | pylon - | man_made | survey_point - | man_made | cutline - | aeroway | no - | amenity | no - | bridge | no - | craft | no - | leisure | no - | office | no - | railway | no - | railway | level_crossing - | shop | no - | tunnel | no - | waterway | riverbank - - Scenario: Some tags only are included when named - Given the osm nodes: - | id | tags - | 1 | '' : '' - | 2 | '' : '', 'name' : 'To Hell' - | 3 | '' : '', 'ref' : '123' - When loading osm data - Then table place has no entry for N1 - And table place has no entry for N3 - And table place contains - | object | class | type - | N2 | | - - Examples: - | key | value - | landuse | residential - | natural | meadow - | highway | traffic_signals - | highway | service - | highway | cycleway - | highway | path - | highway | footway - | highway | steps - | highway | bridleway - | highway | track - | highway | byway - | highway | motorway_link - | highway | primary_link - | highway | trunk_link - | highway | secondary_link - | highway | tertiary_link - | railway | rail - | boundary | administrative - | waterway | stream - - Scenario: Footways are not included if they are sidewalks - Given the osm nodes: - | id | tags - | 2 | 'highway' : 'footway', 'name' : 'To Hell', 'footway' : 'sidewalk' - | 23 | 'highway' : 'footway', 'name' : 'x' - When loading osm data - Then table place has no entry for N2 - - Scenario: named junctions are included if there is no other tag - Given the osm nodes: - | id | tags - | 1 | 'junction' : 'yes' - | 2 | 'highway' : 'secondary', 'junction' : 'roundabout', 'name' : 'To Hell' - | 3 | 'junction' : 'yes', 'name' : 'Le Croix' - When loading osm data - Then table place has no entry for N1 - And table place has no entry for N2:junction - And table place contains - | object | class | type - | N3 | junction | yes - - Scenario: Boundary with place tag - Given the osm nodes: - | id | geometry - | 200 | 0 0 - | 201 | 0 1 - | 202 | 1 1 - | 203 | 1 0 - And the osm ways: - | id | tags | nodes - | 2 | 'boundary' : 'administrative', 'place' : 'city', 'name' : 'Foo' | 200 201 202 203 200 - | 4 | 'boundary' : 'administrative', 'place' : 'island','name' : 'Foo' | 200 201 202 203 200 - | 20 | 'place' : 'city', 'name' : 'ngng' | 200 201 202 203 200 - | 40 | 'place' : 'city', 'boundary' : 'statistical', 'name' : 'BB' | 200 201 202 203 200 - When loading osm data - Then table place contains - | object | class | extratags | type - | W2 | boundary | 'place' : 'city' | administrative - | W4:boundary | boundary | None | administrative - | W4:place | place | None | island - | W20 | place | None | city - | W40:boundary | boundary | None | statistical - | W40:place | place | None | city - And table place has no entry for W2:place - - Scenario Outline: Tags that describe a house - Given the osm nodes: - | id | tags - | 100 | '' : '' - | 999 | 'amenity' : 'prison', '' : '' - When loading osm data - Then table place contains - | object | class | type - | N100 | place | house - | N999 | amenity | prison - And table place has no entry for N100: - And table place has no entry for N999: - And table place has no entry for N999:place - - Examples: - | key | value - | addr:housename | My Mansion - | addr:housenumber | 456 - | addr:conscriptionnumber | 4 - | addr:streetnumber | 4568765 - - Scenario: Only named with no other interesting tag - Given the osm nodes: - | id | tags - | 1 | 'landuse' : 'meadow' - | 2 | 'landuse' : 'residential', 'name' : 'important' - | 3 | 'landuse' : 'residential', 'name' : 'important', 'place' : 'hamlet' - When loading osm data - Then table place contains - | object | class | type - | N2 | landuse | residential - | N3 | place | hamlet - And table place has no entry for N1 - And table place has no entry for N3:landuse - - Scenario Outline: Import of postal codes - Given the osm nodes: - | id | tags - | 10 | 'highway' : 'secondary', '' : '' - | 11 | '' : '' - When loading osm data - Then table place contains - | object | class | type | postcode - | N10 | highway | secondary | - | N11 | place | postcode | - And table place has no entry for N10:place - - Examples: - | key | value - | postal_code | 45736 - | postcode | xxx - | addr:postcode | 564 - | tiger:zip_left | 00011 - | tiger:zip_right | 09123 - - Scenario: Import of street and place - Given the osm nodes: - | id | tags - | 10 | 'amenity' : 'hospital', 'addr:street' : 'Foo St' - | 20 | 'amenity' : 'hospital', 'addr:place' : 'Foo Town' - When loading osm data - Then table place contains - | object | class | type | street | addr_place - | N10 | amenity | hospital | Foo St | None - | N20 | amenity | hospital | None | Foo Town - - - Scenario Outline: Import of country - Given the osm nodes: - | id | tags - | 10 | 'place' : 'village', '' : '' - When loading osm data - Then table place contains - | object | class | type | country_code - | N10 | place | village | - - Examples: - | key | value - | country_code | us - | ISO3166-1 | XX - | is_in:country_code | __ - | addr:country | .. - | addr:country_code | cv - - Scenario Outline: Ignore country codes with wrong length - Given the osm nodes: - | id | tags - | 10 | 'place' : 'village', 'country_code' : '' - When loading osm data - Then table place contains - | object | class | type | country_code - | N10 | place | village | None - - Examples: - | value - | X - | x - | ger - | dkeufr - | d e - - Scenario: Import of house numbers - Given the osm nodes: - | id | tags - | 10 | 'building' : 'yes', 'addr:housenumber' : '4b' - | 11 | 'building' : 'yes', 'addr:conscriptionnumber' : '003' - | 12 | 'building' : 'yes', 'addr:streetnumber' : '2345' - | 13 | 'building' : 'yes', 'addr:conscriptionnumber' : '3', 'addr:streetnumber' : '111' - When loading osm data - Then table place contains - | object | class | type | housenumber - | N10 | building | yes | 4b - | N11 | building | yes | 003 - | N12 | building | yes | 2345 - | N13 | building | yes | 3/111 - - Scenario: Import of address interpolations - Given the osm nodes: - | id | tags - | 10 | 'addr:interpolation' : 'odd' - | 11 | 'addr:housenumber' : '10', 'addr:interpolation' : 'odd' - | 12 | 'addr:interpolation' : 'odd', 'addr:housenumber' : '23' - When loading osm data - Then table place contains - | object | class | type | housenumber - | N10 | place | houses | odd - | N11 | place | houses | odd - | N12 | place | houses | odd - - Scenario: Shorten tiger:county tags - Given the osm nodes: - | id | tags - | 10 | 'place' : 'village', 'tiger:county' : 'Feebourgh, AL' - | 11 | 'place' : 'village', 'addr:state' : 'Alabama', 'tiger:county' : 'Feebourgh, AL' - | 12 | 'place' : 'village', 'tiger:county' : 'Feebourgh' - When loading osm data - Then table place contains - | object | class | type | isin - | N10 | place | village | Feebourgh county - | N11 | place | village | Feebourgh county,Alabama - | N12 | place | village | Feebourgh county - - Scenario Outline: Import of address tags - Given the osm nodes: - | id | tags - | 10 | 'place' : 'village', '' : '' - When loading osm data - Then table place contains - | object | class | type | isin - | N10 | place | village | - - Examples: - | key | value - | is_in | Stockholm, Sweden - | is_in:country | Xanadu - | addr:suburb | hinein - | addr:county | le havre - | addr:city | Sydney - | addr:state | Jura - - Scenario: Import of admin level - Given the osm nodes: - | id | tags - | 10 | 'amenity' : 'hospital', 'admin_level' : '3' - | 11 | 'amenity' : 'hospital', 'admin_level' : 'b' - | 12 | 'amenity' : 'hospital' - | 13 | 'amenity' : 'hospital', 'admin_level' : '3.0' - When loading osm data - Then table place contains - | object | class | type | admin_level - | N10 | amenity | hospital | 3 - | N11 | amenity | hospital | 100 - | N12 | amenity | hospital | 100 - | N13 | amenity | hospital | 3 - - Scenario: Import of extra tags - Given the osm nodes: - | id | tags - | 10 | 'tourism' : 'hotel', '' : 'foo' - When loading osm data - Then table place contains - | object | class | type | extratags - | N10 | tourism | hotel | '' : 'foo' - - Examples: - | key - | tracktype - | traffic_calming - | service - | cuisine - | capital - | dispensing - | religion - | denomination - | sport - | internet_access - | lanes - | surface - | smoothness - | width - | est_width - | incline - | opening_hours - | collection_times - | service_times - | disused - | wheelchair - | sac_scale - | trail_visibility - | mtb:scale - | mtb:description - | wood - | drive_in - | access - | vehicle - | bicyle - | foot - | goods - | hgv - | motor_vehicle - | motor_car - | access:foot - | contact:phone - | drink:mate - | oneway - | date_on - | date_off - | day_on - | day_off - | hour_on - | hour_off - | maxweight - | maxheight - | maxspeed - | disused - | toll - | charge - | population - | description - | image - | attribution - | fax - | email - | url - | website - | phone - | real_ale - | smoking - | food - | camera - | brewery - | locality - | wikipedia - | wikipedia:de - | wikidata - | name:prefix - | name:botanical - | name:etymology:wikidata - - Scenario: buildings - Given the osm nodes: - | id | tags - | 10 | 'tourism' : 'hotel', 'building' : 'yes' - | 11 | 'building' : 'house' - | 12 | 'building' : 'shed', 'addr:housenumber' : '1' - | 13 | 'building' : 'yes', 'name' : 'Das Haus' - | 14 | 'building' : 'yes', 'addr:postcode' : '12345' - When loading osm data - Then table place contains - | object | class | type - | N10 | tourism | hotel - | N12 | building| yes - | N13 | building| yes - | N14 | building| yes - And table place has no entry for N10:building - And table place has no entry for N11 - - Scenario: complete node entry - Given the osm nodes: - | id | tags - | 290393920 | 'addr:city':'Perpignan','addr:country':'FR','addr:housenumber':'43\\','addr:postcode':'66000','addr:street':'Rue Pierre Constant d`Ivry','source':'cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre ; mise à jour :2008' - When loading osm data - Then table place contains - | object | class | type | housenumber - | N290393920 | place | house| 43\ diff --git a/tests/features/osm2pgsql/update/relation.feature b/tests/features/osm2pgsql/update/relation.feature deleted file mode 100644 index f7bf53aa..00000000 --- a/tests/features/osm2pgsql/update/relation.feature +++ /dev/null @@ -1,152 +0,0 @@ -@DB -Feature: Update of relations by osm2pgsql - Testing relation update by osm2pgsql. - -Scenario: Remove all members of a relation - Given the osm nodes: - | id | tags - | 1 | 'amenity' : 'prison', 'name' : 'foo' - Given the osm nodes: - | id | geometry - | 200 | 0 0 - | 201 | 0 0.0001 - | 202 | 0.0001 0.0001 - | 203 | 0.0001 0 - Given the osm ways: - | id | tags | nodes - | 2 | 'ref' : '45' | 200 201 202 203 200 - Given the osm relations: - | id | tags | members - | 1 | 'type' : 'multipolygon', 'tourism' : 'hotel', 'name' : 'XZ' | W2 - When loading osm data - Then table place contains - | object | class | type | name - | R1 | tourism | hotel | 'name' : 'XZ' - Given the osm relations: - | action | id | tags | members - | M | 1 | 'type' : 'multipolygon', 'tourism' : 'hotel', 'name' : 'XZ' | N1 - When updating osm data - Then table place has no entry for R1 - - -Scenario: Change type of a relation - Given the osm nodes: - | id | geometry - | 200 | 0 0 - | 201 | 0 0.0001 - | 202 | 0.0001 0.0001 - | 203 | 0.0001 0 - Given the osm ways: - | id | tags | nodes - | 2 | 'ref' : '45' | 200 201 202 203 200 - Given the osm relations: - | id | tags | members - | 1 | 'type' : 'multipolygon', 'tourism' : 'hotel', 'name' : 'XZ' | W2 - When loading osm data - Then table place contains - | object | class | type | name - | R1 | tourism | hotel | 'name' : 'XZ' - Given the osm relations: - | action | id | tags | members - | M | 1 | 'type' : 'multipolygon', 'amenity' : 'prison', 'name' : 'XZ' | W2 - When updating osm data - Then table place has no entry for R1:tourism - And table place contains - | object | class | type | name - | R1 | amenity | prison | 'name' : 'XZ' - -Scenario: Change name of a relation - Given the osm nodes: - | id | geometry - | 200 | 0 0 - | 201 | 0 0.0001 - | 202 | 0.0001 0.0001 - | 203 | 0.0001 0 - Given the osm ways: - | id | tags | nodes - | 2 | 'ref' : '45' | 200 201 202 203 200 - Given the osm relations: - | id | tags | members - | 1 | 'type' : 'multipolygon', 'tourism' : 'hotel', 'name' : 'AB' | W2 - When loading osm data - Then table place contains - | object | class | type | name - | R1 | tourism | hotel | 'name' : 'AB' - Given the osm relations: - | action | id | tags | members - | M | 1 | 'type' : 'multipolygon', 'tourism' : 'hotel', 'name' : 'XZ' | W2 - When updating osm data - Then table place contains - | object | class | type | name - | R1 | tourism | hotel | 'name' : 'XZ' - - -Scenario: Change type of a relation into something unknown - Given the osm nodes: - | id | geometry - | 200 | 0 0 - | 201 | 0 0.0001 - | 202 | 0.0001 0.0001 - | 203 | 0.0001 0 - Given the osm ways: - | id | tags | nodes - | 2 | 'ref' : '45' | 200 201 202 203 200 - Given the osm relations: - | id | tags | members - | 1 | 'type' : 'multipolygon', 'tourism' : 'hotel', 'name' : 'XZ' | W2 - When loading osm data - Then table place contains - | object | class | type | name - | R1 | tourism | hotel | 'name' : 'XZ' - Given the osm relations: - | action | id | tags | members - | M | 1 | 'type' : 'multipolygon', 'amenities' : 'prison', 'name' : 'XZ' | W2 - When updating osm data - Then table place has no entry for R1 - -Scenario: Type tag is removed - Given the osm nodes: - | id | geometry - | 200 | 0 0 - | 201 | 0 0.0001 - | 202 | 0.0001 0.0001 - | 203 | 0.0001 0 - Given the osm ways: - | id | tags | nodes - | 2 | 'ref' : '45' | 200 201 202 203 200 - Given the osm relations: - | id | tags | members - | 1 | 'type' : 'multipolygon', 'tourism' : 'hotel', 'name' : 'XZ' | W2 - When loading osm data - Then table place contains - | object | class | type | name - | R1 | tourism | hotel | 'name' : 'XZ' - Given the osm relations: - | action | id | tags | members - | M | 1 | 'tourism' : 'hotel', 'name' : 'XZ' | W2 - When updating osm data - Then table place has no entry for R1 - -Scenario: Type tag is renamed to something unknown - Given the osm nodes: - | id | geometry - | 200 | 0 0 - | 201 | 0 0.0001 - | 202 | 0.0001 0.0001 - | 203 | 0.0001 0 - Given the osm ways: - | id | tags | nodes - | 2 | 'ref' : '45' | 200 201 202 203 200 - Given the osm relations: - | id | tags | members - | 1 | 'type' : 'multipolygon', 'tourism' : 'hotel', 'name' : 'XZ' | W2 - When loading osm data - Then table place contains - | object | class | type | name - | R1 | tourism | hotel | 'name' : 'XZ' - Given the osm relations: - | action | id | tags | members - | M | 1 | 'type' : 'multipolygonn', 'tourism' : 'hotel', 'name' : 'XZ' | W2 - When updating osm data - Then table place has no entry for R1 - diff --git a/tests/features/osm2pgsql/update/simple.feature b/tests/features/osm2pgsql/update/simple.feature deleted file mode 100644 index e0c9b005..00000000 --- a/tests/features/osm2pgsql/update/simple.feature +++ /dev/null @@ -1,22 +0,0 @@ -@DB -Feature: Update of simple objects by osm2pgsql - Testing basic update functions of osm2pgsql. - - Scenario: Import object with two main tags - Given the osm nodes: - | id | tags - | 1 | 'tourism' : 'hotel', 'amenity' : 'restaurant', 'name' : 'foo' - When loading osm data - Then table place contains - | object | class | type | name - | N1:tourism | tourism | hotel | 'name' : 'foo' - | N1:amenity | amenity | restaurant | 'name' : 'foo' - Given the osm nodes: - | action | id | tags - | M | 1 | 'tourism' : 'hotel', 'name' : 'foo' - When updating osm data - Then table place has no entry for N1:amenity - And table place contains - | object | class | type | name - | N1:tourism | tourism | hotel | 'name' : 'foo' - diff --git a/tests/steps/api_result.py b/tests/steps/api_result.py deleted file mode 100644 index 2644d4a2..00000000 --- a/tests/steps/api_result.py +++ /dev/null @@ -1,286 +0,0 @@ -""" Steps for checking the results of queries. -""" - -from nose.tools import * -from lettuce import * -from tidylib import tidy_document -from collections import OrderedDict -import json -import logging -import re -from xml.dom.minidom import parseString - -logger = logging.getLogger(__name__) - -def _parse_xml(): - """ Puts the DOM structure into more convenient python - with a similar structure as the json document, so - that the same the semantics can be used. It does not - check if the content is valid (or at least not more than - necessary to transform it into a dict structure). - """ - page = parseString(world.page).documentElement - - # header info - world.result_header = OrderedDict(page.attributes.items()) - logger.debug('Result header: %r' % (world.result_header)) - world.results = [] - - # results - if page.nodeName == 'searchresults' or page.nodeName == 'lookupresults': - for node in page.childNodes: - if node.nodeName != "#text": - assert_equals(node.nodeName, 'place', msg="Unexpected element '%s'" % node.nodeName) - newresult = OrderedDict(node.attributes.items()) - assert_not_in('address', newresult) - assert_not_in('geokml', newresult) - assert_not_in('extratags', newresult) - assert_not_in('namedetails', newresult) - address = OrderedDict() - for sub in node.childNodes: - if sub.nodeName == 'geokml': - newresult['geokml'] = sub.childNodes[0].toxml() - elif sub.nodeName == 'extratags': - newresult['extratags'] = {} - for tag in sub.childNodes: - assert_equals(tag.nodeName, 'tag') - attrs = dict(tag.attributes.items()) - assert_in('key', attrs) - assert_in('value', attrs) - newresult['extratags'][attrs['key']] = attrs['value'] - elif sub.nodeName == 'namedetails': - newresult['namedetails'] = {} - for tag in sub.childNodes: - assert_equals(tag.nodeName, 'name') - attrs = dict(tag.attributes.items()) - assert_in('desc', attrs) - newresult['namedetails'][attrs['desc']] = tag.firstChild.nodeValue.strip() - - elif sub.nodeName == '#text': - pass - else: - address[sub.nodeName] = sub.firstChild.nodeValue.strip() - if address: - newresult['address'] = address - world.results.append(newresult) - elif page.nodeName == 'reversegeocode': - haserror = False - address = {} - for node in page.childNodes: - if node.nodeName == 'result': - assert_equals(len(world.results), 0) - assert (not haserror) - world.results.append(OrderedDict(node.attributes.items())) - assert_not_in('display_name', world.results[0]) - assert_not_in('address', world.results[0]) - world.results[0]['display_name'] = node.firstChild.nodeValue.strip() - elif node.nodeName == 'error': - assert_equals(len(world.results), 0) - haserror = True - elif node.nodeName == 'addressparts': - assert (not haserror) - address = OrderedDict() - for sub in node.childNodes: - address[sub.nodeName] = sub.firstChild.nodeValue.strip() - world.results[0]['address'] = address - elif node.nodeName == 'extratags': - world.results[0]['extratags'] = {} - for tag in node.childNodes: - assert_equals(tag.nodeName, 'tag') - attrs = dict(tag.attributes.items()) - assert_in('key', attrs) - assert_in('value', attrs) - world.results[0]['extratags'][attrs['key']] = attrs['value'] - elif node.nodeName == 'namedetails': - world.results[0]['namedetails'] = {} - for tag in node.childNodes: - assert_equals(tag.nodeName, 'name') - attrs = dict(tag.attributes.items()) - assert_in('desc', attrs) - world.results[0]['namedetails'][attrs['desc']] = tag.firstChild.nodeValue.strip() - elif node.nodeName == "geokml": - world.results[0]['geokml'] = node - elif node.nodeName == "#text": - pass - else: - assert False, "Unknown content '%s' in XML" % node.nodeName - else: - assert False, "Unknown document node name %s in XML" % page.nodeName - - logger.debug("The following was parsed out of XML:") - logger.debug(world.results) - -@step(u'a HTTP (\d+) is returned') -def api_result_http_error(step, error): - assert_equals(world.returncode, int(error)) - -@step(u'the result is valid( \w+)?') -def api_result_is_valid(step, fmt): - assert_equals(world.returncode, 200) - - if world.response_format == 'html': - document, errors = tidy_document(world.page, - options={'char-encoding' : 'utf8'}) - # assert(len(errors) == 0), "Errors found in HTML document:\n%s" % errors - world.results = document - elif world.response_format == 'xml': - _parse_xml() - elif world.response_format == 'json': - world.results = json.JSONDecoder(object_pairs_hook=OrderedDict).decode(world.page) - if world.request_type == 'reverse': - world.results = (world.results,) - else: - assert False, "Unknown page format: %s" % (world.response_format) - - if fmt: - assert_equals (fmt.strip(), world.response_format) - - -def compare(operator, op1, op2): - if operator == 'less than': - return op1 < op2 - elif operator == 'more than': - return op1 > op2 - elif operator == 'exactly': - return op1 == op2 - elif operator == 'at least': - return op1 >= op2 - elif operator == 'at most': - return op1 <= op2 - else: - raise Exception("unknown operator '%s'" % operator) - -@step(u'(less than|more than|exactly|at least|at most) (\d+) results? (?:is|are) returned') -def validate_result_number(step, operator, number): - step.given('the result is valid') - numres = len(world.results) - assert compare(operator, numres, int(number)), \ - "Bad number of results: expected %s %s, got %d." % (operator, number, numres) - -@step(u'result (\d+) has( not)? attributes (\S+)') -def search_check_for_result_attribute(step, num, invalid, attrs): - num = int(num) - step.given('at least %d results are returned' % (num + 1)) - res = world.results[num] - for attr in attrs.split(','): - if invalid: - assert_not_in(attr.strip(), res) - else: - assert_in(attr.strip(),res) - -@step(u'there is a json wrapper "([^"]*)"') -def api_result_check_json_wrapper(step, wrapper): - step.given('the result is valid json') - assert_equals(world.json_callback, wrapper) - -@step(u'result header contains') -def api_result_header_contains(step): - step.given('the result is valid') - for line in step.hashes: - assert_in(line['attr'], world.result_header) - m = re.match("%s$" % (line['value'],), world.result_header[line['attr']]) - -@step(u'result header has no attribute (.*)') -def api_result_header_contains_not(step, attr): - step.given('the result is valid') - assert_not_in(attr, world.result_header) - -@step(u'results contain$') -def api_result_contains(step): - step.given('at least 1 result is returned') - for line in step.hashes: - if 'ID' in line: - reslist = (world.results[int(line['ID'])],) - else: - reslist = world.results - for k,v in line.iteritems(): - if k == 'latlon': - for curres in reslist: - world.match_geometry((float(curres['lat']), float(curres['lon'])), v) - elif k != 'ID': - for curres in reslist: - assert_in(k, curres) - if v[0] in '<>=': - # mathematical operation - evalexp = '%s %s' % (curres[k], v) - res = eval(evalexp) - logger.debug('Evaluating: %s = %s' % (res, evalexp)) - assert_true(res, "Evaluation failed: %s" % (evalexp, )) - else: - # regex match - m = re.match("%s$" % (v,), curres[k]) - assert_is_not_none(m, msg="field %s does not match: %s$ != %s." % (k, v, curres[k])) - -@step(u'results contain valid boundingboxes$') -def api_result_address_contains(step): - step.given('the result is valid') - for curres in world.results: - bb = curres['boundingbox'] - if world.response_format == 'json': - bb = ','.join(bb) - m = re.match('^(-?\d+\.\d+),(-?\d+\.\d+),(-?\d+\.\d+),(-?\d+\.\d+)$', bb) - assert_is_not_none(m, msg="invalid boundingbox: %s." % (curres['boundingbox'])) - -@step(u'result addresses contain$') -def api_result_address_contains(step): - step.given('the result is valid') - for line in step.hashes: - if 'ID' in line: - reslist = (world.results[int(line['ID'])],) - else: - reslist = world.results - for k,v in line.iteritems(): - if k != 'ID': - for res in reslist: - curres = res['address'] - assert_in(k, curres) - m = re.match("%s$" % (v,), curres[k]) - assert_is_not_none(m, msg="field %s does not match: %s$ != %s." % (k, v, curres[k])) - - -@step(u'address of result (\d+) contains') -def api_result_address_exact(step, resid): - resid = int(resid) - step.given('at least %d results are returned' % (resid + 1)) - addr = world.results[resid]['address'] - for line in step.hashes: - assert_in(line['type'], addr) - m = re.match("%s$" % line['value'], addr[line['type']]) - assert_is_not_none(m, msg="field %s does not match: %s$ != %s." % ( - line['type'], line['value'], addr[line['type']])) - #assert_equals(line['value'], addr[line['type']]) - -@step(u'address of result (\d+) does not contain (.*)') -def api_result_address_details_missing(step, resid, types): - resid = int(resid) - step.given('at least %d results are returned' % (resid + 1)) - addr = world.results[resid]['address'] - for t in types.split(','): - assert_not_in(t.strip(), addr) - - -@step(u'address of result (\d+) is') -def api_result_address_exact(step, resid): - resid = int(resid) - step.given('at least %d results are returned' % (resid + 1)) - result = world.results[resid] - linenr = 0 - assert_equals(len(step.hashes), len(result['address'])) - for k,v in result['address'].iteritems(): - assert_equals(step.hashes[linenr]['type'], k) - assert_equals(step.hashes[linenr]['value'], v) - linenr += 1 - - -@step('there are( no)? duplicates') -def api_result_check_for_duplicates(step, nodups=None): - step.given('at least 1 result is returned') - resarr = [] - for res in world.results: - resarr.append((res['osm_type'], res['class'], - res['type'], res['display_name'])) - - if nodups is None: - assert len(resarr) > len(set(resarr)) - else: - assert_equal(len(resarr), len(set(resarr))) diff --git a/tests/steps/api_setup.py b/tests/steps/api_setup.py deleted file mode 100644 index c9a4bac4..00000000 --- a/tests/steps/api_setup.py +++ /dev/null @@ -1,136 +0,0 @@ -""" Steps for setting up and sending API requests. -""" - -from nose.tools import * -from lettuce import * -import urllib -import urllib2 -import logging - -logger = logging.getLogger(__name__) - -def api_call(requesttype): - world.request_type = requesttype - world.json_callback = None - data = urllib.urlencode(world.params) - url = "%s/%s?%s" % (world.config.base_url, requesttype, data) - req = urllib2.Request(url=url, headers=world.header) - try: - fd = urllib2.urlopen(req) - world.page = fd.read() - world.returncode = 200 - except urllib2.HTTPError, ex: - world.returncode = ex.code - world.page = None - return - - pageinfo = fd.info() - assert_equal('utf-8', pageinfo.getparam('charset').lower()) - pagetype = pageinfo.gettype() - - fmt = world.params.get('format') - if fmt == 'html': - assert_equals('text/html', pagetype) - world.response_format = fmt - elif fmt == 'xml': - assert_equals('text/xml', pagetype) - world.response_format = fmt - elif fmt in ('json', 'jsonv2'): - if 'json_callback' in world.params: - world.json_callback = world.params['json_callback'].encode('utf8') - assert world.page.startswith(world.json_callback + '(') - assert world.page.endswith(')') - world.page = world.page[(len(world.json_callback)+1):-1] - assert_equals('application/javascript', pagetype) - else: - assert_equals('application/json', pagetype) - world.response_format = 'json' - else: - if requesttype == 'reverse': - assert_equals('text/xml', pagetype) - world.response_format = 'xml' - else: - assert_equals('text/html', pagetype) - world.response_format = 'html' - logger.debug("Page received (%s):" % world.response_format) - logger.debug(world.page) - - api_setup_prepare_params(None) - -@before.each_scenario -def api_setup_prepare_params(scenario): - world.results = [] - world.params = {} - world.header = {} - -@step(u'the request parameters$') -def api_setup_parameters(step): - """Define the parameters of the request as a hash. - Resets parameter list. - """ - world.params = step.hashes[0] - -@step(u'the HTTP header$') -def api_setup_parameters(step): - """Define additional HTTP header parameters as a hash. - Resets parameter list. - """ - world.header = step.hashes[0] - - -@step(u'sending( \w+)? search query "([^"]*)"( with address)?') -def api_setup_search(step, fmt, query, doaddr): - world.params['q'] = query.encode('utf8') - if doaddr: - world.params['addressdetails'] = 1 - if fmt: - world.params['format'] = fmt.strip() - api_call('search') - -@step(u'sending( \w+)? structured query( with address)?$') -def api_setup_structured_search(step, fmt, doaddr): - world.params.update(step.hashes[0]) - if doaddr: - world.params['addressdetails'] = 1 - if fmt: - world.params['format'] = fmt.strip() - api_call('search') - -@step(u'looking up (\w+ )?coordinates ([-\d.]+),([-\d.]+)') -def api_setup_reverse(step, fmt, lat, lon): - world.params['lat'] = lat - world.params['lon'] = lon - if fmt and fmt.strip(): - world.params['format'] = fmt.strip() - api_call('reverse') - -@step(u'looking up place ([NRW]?\d+)') -def api_setup_details_reverse(step, obj): - if obj[0] in ('N', 'R', 'W'): - # an osm id - world.params['osm_type'] = obj[0] - world.params['osm_id'] = obj[1:] - else: - world.params['place_id'] = obj - api_call('reverse') - -@step(u'looking up details for ([NRW]?\d+)') -def api_setup_details(step, obj): - if obj[0] in ('N', 'R', 'W'): - # an osm id - world.params['osmtype'] = obj[0] - world.params['osmid'] = obj[1:] - else: - world.params['place_id'] = obj - api_call('details') - -@step(u'looking up (\w+) places ((?:[a-z]\d+,*)+)') -def api_setup_lookup(step, fmt, ids): - world.params['osm_ids'] = ids - if fmt and fmt.strip(): - world.params['format'] = fmt.strip() - api_call('lookup') - -@step(u'sending an API call (\w+)') -def api_general_call(step, call): - api_call(call) diff --git a/tests/steps/db_results.py b/tests/steps/db_results.py deleted file mode 100644 index 53374e71..00000000 --- a/tests/steps/db_results.py +++ /dev/null @@ -1,201 +0,0 @@ -""" Steps for checking the DB after import and update tests. - - There are two groups of test here. The first group tests - the contents of db tables directly, the second checks - query results by using the command line query tool. -""" - -from nose.tools import * -from lettuce import * -import psycopg2 -import psycopg2.extensions -import psycopg2.extras -import os -import random -import json -import re -import logging -from collections import OrderedDict - -logger = logging.getLogger(__name__) - -@step(u'table placex contains as names for (N|R|W)(\d+)') -def check_placex_names(step, osmtyp, osmid): - """ Check for the exact content of the name hstore in placex. - """ - cur = world.conn.cursor(cursor_factory=psycopg2.extras.DictCursor) - cur.execute('SELECT name FROM placex where osm_type = %s and osm_id =%s', (osmtyp, int(osmid))) - for line in cur: - names = dict(line['name']) - for name in step.hashes: - assert_in(name['k'], names) - assert_equals(names[name['k']], name['v']) - del names[name['k']] - assert_equals(len(names), 0) - - - - -@step(u'table ([a-z_]+) contains$') -def check_placex_content(step, tablename): - """ check that the given lines are in the given table - Entries are searched by osm_type/osm_id and then all - given columns are tested. If there is more than one - line for an OSM object, they must match in these columns. - """ - try: - cur = world.conn.cursor(cursor_factory=psycopg2.extras.DictCursor) - for line in step.hashes: - osmtype, osmid, cls = world.split_id(line['object']) - q = 'SELECT *' - if tablename == 'placex': - q = q + ", ST_X(centroid) as clat, ST_Y(centroid) as clon" - if tablename == 'location_property_osmline': - q = q + ' FROM %s where osm_id = %%s' % (tablename,) - else: - q = q + ", ST_GeometryType(geometry) as geometrytype" - q = q + ' FROM %s where osm_type = %%s and osm_id = %%s' % (tablename,) - if cls is None: - if tablename == 'location_property_osmline': - params = (osmid,) - else: - params = (osmtype, osmid) - else: - q = q + ' and class = %s' - if tablename == 'location_property_osmline': - params = (osmid, cls) - else: - params = (osmtype, osmid, cls) - cur.execute(q, params) - assert(cur.rowcount > 0) - for res in cur: - for k,v in line.iteritems(): - if not k == 'object': - assert_in(k, res) - if type(res[k]) is dict: - val = world.make_hash(v) - assert_equals(res[k], val) - elif k in ('parent_place_id', 'linked_place_id'): - pid = world.get_placeid(v) - assert_equals(pid, res[k], "Results for '%s'/'%s' differ: '%s' != '%s'" % (line['object'], k, pid, res[k])) - elif k == 'centroid': - world.match_geometry((res['clat'], res['clon']), v) - else: - assert_equals(str(res[k]), v, "Results for '%s'/'%s' differ: '%s' != '%s'" % (line['object'], k, str(res[k]), v)) - finally: - cur.close() - world.conn.commit() - -@step(u'table (placex?) has no entry for (N|R|W)(\d+)(:\w+)?') -def check_placex_missing(step, tablename, osmtyp, osmid, placeclass): - cur = world.conn.cursor() - try: - q = 'SELECT count(*) FROM %s where osm_type = %%s and osm_id = %%s' % (tablename, ) - args = [osmtyp, int(osmid)] - if placeclass is not None: - q = q + ' and class = %s' - args.append(placeclass[1:]) - cur.execute(q, args) - numres = cur.fetchone()[0] - assert_equals (numres, 0) - finally: - cur.close() - world.conn.commit() - -@step(u'table location_property_osmline has no entry for W(\d+)?') -def check_osmline_missing(step, osmid): - cur = world.conn.cursor() - try: - q = 'SELECT count(*) FROM location_property_osmline where osm_id = %s' % (osmid, ) - cur.execute(q) - numres = cur.fetchone()[0] - assert_equals (numres, 0) - finally: - cur.close() - world.conn.commit() - -@step(u'search_name table contains$') -def check_search_name_content(step): - cur = world.conn.cursor(cursor_factory=psycopg2.extras.DictCursor) - for line in step.hashes: - placeid = world.get_placeid(line['place_id']) - cur.execute('SELECT * FROM search_name WHERE place_id = %s', (placeid,)) - assert(cur.rowcount > 0) - for res in cur: - for k,v in line.iteritems(): - if k in ('search_rank', 'address_rank'): - assert_equals(int(v), res[k], "Results for '%s'/'%s' differ: '%s' != '%d'" % (line['place_id'], k, v, res[k])) - elif k in ('importance'): - assert_equals(float(v), res[k], "Results for '%s'/'%s' differ: '%s' != '%d'" % (line['place_id'], k, v, res[k])) - elif k in ('name_vector', 'nameaddress_vector'): - terms = [x.strip().replace('#', ' ') for x in v.split(',')] - cur.execute('SELECT word_id, word_token FROM word, (SELECT unnest(%s) as term) t WHERE word_token = make_standard_name(t.term)', (terms,)) - assert cur.rowcount >= len(terms) - for wid in cur: - assert_in(wid['word_id'], res[k], "Missing term for %s/%s: %s" % (line['place_id'], k, wid['word_token'])) - elif k in ('country_code'): - assert_equals(v, res[k], "Results for '%s'/'%s' differ: '%s' != '%d'" % (line['place_id'], k, v, res[k])) - elif k == 'place_id': - pass - else: - raise Exception("Cannot handle field %s in search_name table" % (k, )) - -@step(u'way (\d+) expands to lines') -def check_interpolation_lines(step, wayid): - """Check that the correct interpolation line has been entered in - location_property_osmline for the given source line/nodes. - Expected are three columns: - startnumber, endnumber and linegeo - """ - lines = [] - for line in step.hashes: - lines.append((line["startnumber"], line["endnumber"], line["geometry"])) - cur = world.conn.cursor(cursor_factory=psycopg2.extras.DictCursor) - cur.execute("""SELECT startnumber::text, endnumber::text, st_astext(linegeo) as geometry - FROM location_property_osmline WHERE osm_id = %s""", - (int(wayid),)) - assert_equals(len(lines), cur.rowcount) - for r in cur: - linegeo = str(str(r["geometry"].split('(')[1]).split(')')[0]).replace(',', ', ') - exp = (r["startnumber"], r["endnumber"], linegeo) - assert_in(exp, lines) - lines.remove(exp) - -@step(u'way (\d+) expands exactly to housenumbers ([0-9,]*)') -def check_interpolated_housenumber_list(step, nodeid, numberlist): - """ Checks that the interpolated house numbers corresponds - to the given list. - """ - expected = numberlist.split(','); - cur = world.conn.cursor() - cur.execute("""SELECT housenumber FROM placex - WHERE osm_type = 'W' and osm_id = %s - and class = 'place' and type = 'address'""", (int(nodeid),)) - for r in cur: - assert_in(r[0], expected, "Unexpected house number %s for node %s." % (r[0], nodeid)) - expected.remove(r[0]) - assert_equals(0, len(expected), "Missing house numbers for way %s: %s" % (nodeid, expected)) - -@step(u'way (\d+) expands to no housenumbers') -def check_no_interpolated_housenumber_list(step, nodeid): - """ Checks that the interpolated house numbers corresponds - to the given list. - """ - cur = world.conn.cursor() - cur.execute("""SELECT housenumber FROM placex - WHERE osm_type = 'W' and osm_id = %s - and class = 'place' and type = 'address'""", (int(nodeid),)) - res = [r[0] for r in cur] - assert_equals(0, len(res), "Unexpected house numbers for way %s: %s" % (nodeid, res)) - -@step(u'table search_name has no entry for (.*)') -def check_placex_missing(step, osmid): - """ Checks if there is an entry in the search index for the - given place object. - """ - cur = world.conn.cursor() - placeid = world.get_placeid(osmid) - cur.execute('SELECT count(*) FROM search_name WHERE place_id =%s', (placeid,)) - numres = cur.fetchone()[0] - assert_equals (numres, 0) - diff --git a/tests/steps/db_setup.py b/tests/steps/db_setup.py deleted file mode 100644 index 727e6105..00000000 --- a/tests/steps/db_setup.py +++ /dev/null @@ -1,278 +0,0 @@ -""" Steps for setting up a test database with imports and updates. - - There are two ways to state geometries for test data: with coordinates - and via scenes. - - Coordinates should be given as a wkt without the enclosing type name. - - Scenes are prepared geometries which can be found in the scenes/data/ - directory. Each scene is saved in a .wkt file with its name, which - contains a list of id/wkt pairs. A scene can be set globally - for a scene by using the step `the scene `. Then each - object should be refered to as `:`. A geometry can also - be referred to without loading the scene by explicitly stating the - scene: `:`. -""" - -from nose.tools import * -from lettuce import * -import psycopg2 -import psycopg2.extensions -import psycopg2.extras -import os -import subprocess -import random -import base64 -import sys - -psycopg2.extensions.register_type(psycopg2.extensions.UNICODE) - -@before.each_scenario -def setup_test_database(scenario): - """ Creates a new test database from the template database - that was set up earlier in terrain.py. Will be done only - for scenarios whose feature is tagged with 'DB'. - """ - if scenario.feature.tags is not None and 'DB' in scenario.feature.tags: - world.db_template_setup() - world.write_nominatim_config(world.config.test_db) - conn = psycopg2.connect(database=world.config.template_db) - conn.set_isolation_level(0) - cur = conn.cursor() - cur.execute('DROP DATABASE IF EXISTS %s' % (world.config.test_db, )) - cur.execute('CREATE DATABASE %s TEMPLATE = %s' % (world.config.test_db, world.config.template_db)) - conn.close() - world.conn = psycopg2.connect(database=world.config.test_db) - psycopg2.extras.register_hstore(world.conn, globally=False, unicode=True) - -@step('a wiped database') -def db_setup_wipe_db(step): - """Explicit DB scenario setup only needed - to work around a bug where scenario outlines don't call - before_each_scenario correctly. - """ - if hasattr(world, 'conn'): - world.conn.close() - conn = psycopg2.connect(database=world.config.template_db) - conn.set_isolation_level(0) - cur = conn.cursor() - cur.execute('DROP DATABASE IF EXISTS %s' % (world.config.test_db, )) - cur.execute('CREATE DATABASE %s TEMPLATE = %s' % (world.config.test_db, world.config.template_db)) - conn.close() - world.conn = psycopg2.connect(database=world.config.test_db) - psycopg2.extras.register_hstore(world.conn, globally=False, unicode=True) - - -@after.each_scenario -def tear_down_test_database(scenario): - """ Drops any previously created test database. - """ - if hasattr(world, 'conn'): - world.conn.close() - if scenario.feature.tags is not None and 'DB' in scenario.feature.tags and not world.config.keep_scenario_db: - conn = psycopg2.connect(database=world.config.template_db) - conn.set_isolation_level(0) - cur = conn.cursor() - cur.execute('DROP DATABASE %s' % (world.config.test_db,)) - conn.close() - - -def _format_placex_cols(cols, geomtype, force_name): - if 'name' in cols: - if cols['name'].startswith("'"): - cols['name'] = world.make_hash(cols['name']) - else: - cols['name'] = { 'name' : cols['name'] } - elif force_name: - cols['name'] = { 'name' : base64.urlsafe_b64encode(os.urandom(int(random.random()*30))) } - if 'extratags' in cols: - cols['extratags'] = world.make_hash(cols['extratags']) - if 'admin_level' not in cols: - cols['admin_level'] = 100 - if 'geometry' in cols: - coords = world.get_scene_geometry(cols['geometry']) - if coords is None: - coords = "'%s(%s)'::geometry" % (geomtype, cols['geometry']) - else: - coords = "'%s'::geometry" % coords.wkt - cols['geometry'] = coords - for k in cols: - if not cols[k]: - cols[k] = None - - -def _insert_place_table_nodes(places, force_name): - cur = world.conn.cursor() - for line in places: - cols = dict(line) - cols['osm_type'] = 'N' - _format_placex_cols(cols, 'POINT', force_name) - if 'geometry' in cols: - coords = cols.pop('geometry') - else: - coords = "ST_Point(%f, %f)" % (random.random()*360 - 180, random.random()*180 - 90) - - query = 'INSERT INTO place (%s,geometry) values(%s, ST_SetSRID(%s, 4326))' % ( - ','.join(cols.iterkeys()), - ','.join(['%s' for x in range(len(cols))]), - coords - ) - cur.execute(query, cols.values()) - world.conn.commit() - - -def _insert_place_table_objects(places, geomtype, force_name): - cur = world.conn.cursor() - for line in places: - cols = dict(line) - if 'osm_type' not in cols: - cols['osm_type'] = 'W' - _format_placex_cols(cols, geomtype, force_name) - coords = cols.pop('geometry') - - query = 'INSERT INTO place (%s, geometry) values(%s, ST_SetSRID(%s, 4326))' % ( - ','.join(cols.iterkeys()), - ','.join(['%s' for x in range(len(cols))]), - coords - ) - cur.execute(query, cols.values()) - world.conn.commit() - -@step(u'the scene (.*)') -def import_set_scene(step, scene): - world.load_scene(scene) - -@step(u'the (named )?place (node|way|area)s') -def import_place_table_nodes(step, named, osmtype): - """Insert a list of nodes into the place table. - Expects a table where columns are named in the same way as place. - """ - cur = world.conn.cursor() - cur.execute('ALTER TABLE place DISABLE TRIGGER place_before_insert') - if osmtype == 'node': - _insert_place_table_nodes(step.hashes, named is not None) - elif osmtype == 'way' : - _insert_place_table_objects(step.hashes, 'LINESTRING', named is not None) - elif osmtype == 'area' : - _insert_place_table_objects(step.hashes, 'POLYGON', named is not None) - cur.execute('ALTER TABLE place ENABLE TRIGGER place_before_insert') - cur.close() - world.conn.commit() - - -@step(u'the relations') -def import_fill_planet_osm_rels(step): - """Adds a raw relation to the osm2pgsql table. - Three columns need to be suplied: id, tags, members. - """ - cur = world.conn.cursor() - for line in step.hashes: - members = [] - parts = { 'n' : [], 'w' : [], 'r' : [] } - if line['members'].strip(): - for mem in line['members'].split(','): - memparts = mem.strip().split(':', 2) - memid = memparts[0].lower() - parts[memid[0]].append(int(memid[1:])) - members.append(memid) - if len(memparts) == 2: - members.append(memparts[1]) - else: - members.append('') - tags = [] - for k,v in world.make_hash(line['tags']).iteritems(): - tags.extend((k,v)) - if not members: - members = None - - cur.execute("""INSERT INTO planet_osm_rels - (id, way_off, rel_off, parts, members, tags) - VALUES (%s, %s, %s, %s, %s, %s)""", - (line['id'], len(parts['n']), len(parts['n']) + len(parts['w']), - parts['n'] + parts['w'] + parts['r'], members, tags)) - world.conn.commit() - - -@step(u'the ways') -def import_fill_planet_osm_ways(step): - cur = world.conn.cursor() - for line in step.hashes: - if 'tags' in line: - tags = world.make_hash(line['tags']) - else: - tags = None - nodes = [int(x.strip()) for x in line['nodes'].split(',')] - - cur.execute("""INSERT INTO planet_osm_ways (id, nodes, tags) - VALUES (%s, %s, %s)""", - (line['id'], nodes, tags)) - world.conn.commit() - -############### import and update steps ####################################### - -@step(u'importing') -def import_database(step): - """ Runs the actual indexing. """ - world.run_nominatim_script('setup', 'create-functions', 'create-partition-functions') - cur = world.conn.cursor() - #world.db_dump_table('place') - cur.execute("""insert into placex (osm_type, osm_id, class, type, name, admin_level, - housenumber, street, addr_place, isin, postcode, country_code, extratags, - geometry) select * from place where not (class='place' and type='houses' and osm_type='W')""") - cur.execute("""select insert_osmline (osm_id, housenumber, street, addr_place, postcode, country_code, geometry) from place where class='place' and type='houses' and osm_type='W'""") - world.conn.commit() - world.run_nominatim_script('setup', 'index', 'index-noanalyse') - #world.db_dump_table('placex') - #world.db_dump_table('location_property_osmline') - -@step(u'updating place (node|way|area)s') -def update_place_table_nodes(step, osmtype): - """ Replace a geometry in place by reinsertion and reindex database.""" - world.run_nominatim_script('setup', 'create-functions', 'create-partition-functions', 'enable-diff-updates') - if osmtype == 'node': - _insert_place_table_nodes(step.hashes, False) - elif osmtype == 'way': - _insert_place_table_objects(step.hashes, 'LINESTRING', False) - elif osmtype == 'area': - _insert_place_table_objects(step.hashes, 'POLYGON', False) - world.run_nominatim_script('update', 'index') - -@step(u'marking for delete (.*)') -def update_delete_places(step, places): - """ Remove an entry from place and reindex database. - """ - world.run_nominatim_script('setup', 'create-functions', 'create-partition-functions', 'enable-diff-updates') - cur = world.conn.cursor() - for place in places.split(','): - osmtype, osmid, cls = world.split_id(place) - if cls is None: - q = "delete from place where osm_type = %s and osm_id = %s" - params = (osmtype, osmid) - else: - q = "delete from place where osm_type = %s and osm_id = %s and class = %s" - params = (osmtype, osmid, cls) - cur.execute(q, params) - world.conn.commit() - #world.db_dump_table('placex') - world.run_nominatim_script('update', 'index') - - - -@step(u'sending query "(.*)"( with dups)?$') -def query_cmd(step, query, with_dups): - """ Results in standard query output. The same tests as for API queries - can be used. - """ - cmd = [os.path.join(world.config.source_dir, 'utils', 'query.php'), - '--search', query] - if with_dups is not None: - cmd.append('--nodedupe') - proc = subprocess.Popen(cmd, cwd=world.config.source_dir, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - (outp, err) = proc.communicate() - assert (proc.returncode == 0), "query.php failed with message: %s" % err - world.page = outp - world.response_format = 'json' - world.request_type = 'search' - world.returncode = 200 - diff --git a/tests/steps/osm2pgsql_setup.py b/tests/steps/osm2pgsql_setup.py deleted file mode 100644 index 4b03b1ea..00000000 --- a/tests/steps/osm2pgsql_setup.py +++ /dev/null @@ -1,214 +0,0 @@ -""" Steps for setting up a test database for osm2pgsql import. - - Note that osm2pgsql features need a database and therefore need - to be tagged with @DB. -""" - -from nose.tools import * -from lettuce import * - -import logging -import random -import tempfile -import os -import subprocess - -logger = logging.getLogger(__name__) - -@before.each_scenario -def osm2pgsql_setup_test(scenario): - world.osm2pgsql = [] - -@step(u'the osm nodes:') -def osm2pgsql_import_nodes(step): - """ Define a list of OSM nodes to be imported, given as a table. - Each line describes one node with all its attributes. - 'id' is mendatory, all other fields are filled with random values - when not given. If 'tags' is missing an empty tag list is assumed. - For updates, a mandatory 'action' column needs to contain 'A' (add), - 'M' (modify), 'D' (delete). - """ - for line in step.hashes: - node = { 'type' : 'N', 'version' : '1', 'timestamp': "2012-05-01T15:06:20Z", - 'changeset' : "11470653", 'uid' : "122294", 'user' : "foo" - } - node.update(line) - node['id'] = int(node['id']) - if 'geometry' in node: - lat, lon = node['geometry'].split(' ') - node['lat'] = float(lat) - node['lon'] = float(lon) - else: - node['lon'] = random.random()*360 - 180 - node['lat'] = random.random()*180 - 90 - if 'tags' in node: - node['tags'] = world.make_hash(line['tags']) - else: - node['tags'] = {} - - world.osm2pgsql.append(node) - - -@step(u'the osm ways:') -def osm2pgsql_import_ways(step): - """ Define a list of OSM ways to be imported. - """ - for line in step.hashes: - way = { 'type' : 'W', 'version' : '1', 'timestamp': "2012-05-01T15:06:20Z", - 'changeset' : "11470653", 'uid' : "122294", 'user' : "foo" - } - way.update(line) - - way['id'] = int(way['id']) - if 'tags' in way: - way['tags'] = world.make_hash(line['tags']) - else: - way['tags'] = None - way['nodes'] = way['nodes'].strip().split() - - world.osm2pgsql.append(way) - -membertype = { 'N' : 'node', 'W' : 'way', 'R' : 'relation' } - -@step(u'the osm relations:') -def osm2pgsql_import_rels(step): - """ Define a list of OSM relation to be imported. - """ - for line in step.hashes: - rel = { 'type' : 'R', 'version' : '1', 'timestamp': "2012-05-01T15:06:20Z", - 'changeset' : "11470653", 'uid' : "122294", 'user' : "foo" - } - rel.update(line) - - rel['id'] = int(rel['id']) - if 'tags' in rel: - rel['tags'] = world.make_hash(line['tags']) - else: - rel['tags'] = {} - members = [] - if rel['members'].strip(): - for mem in line['members'].split(','): - memparts = mem.strip().split(':', 2) - memid = memparts[0].upper() - members.append((membertype[memid[0]], - memid[1:], - memparts[1] if len(memparts) == 2 else '' - )) - rel['members'] = members - - world.osm2pgsql.append(rel) - - - -def _sort_xml_entries(x, y): - if x['type'] == y['type']: - return cmp(x['id'], y['id']) - else: - return cmp('NWR'.find(x['type']), 'NWR'.find(y['type'])) - -def write_osm_obj(fd, obj): - if obj['type'] == 'N': - fd.write('\n') - else: - fd.write('>\n') - for k,v in obj['tags'].iteritems(): - fd.write(' \n' % (k, v)) - fd.write('\n') - elif obj['type'] == 'W': - fd.write('\n' % obj) - for nd in obj['nodes']: - fd.write('\n' % (nd,)) - for k,v in obj['tags'].iteritems(): - fd.write(' \n' % (k, v)) - fd.write('\n') - elif obj['type'] == 'R': - fd.write('\n' % obj) - for mem in obj['members']: - fd.write(' \n' % mem) - for k,v in obj['tags'].iteritems(): - fd.write(' \n' % (k, v)) - fd.write('\n') - -@step(u'loading osm data') -def osm2pgsql_load_place(step): - """Imports the previously defined OSM data into a fresh copy of a - Nominatim test database. - """ - - world.osm2pgsql.sort(cmp=_sort_xml_entries) - - # create a OSM file in /tmp - with tempfile.NamedTemporaryFile(dir='/tmp', suffix='.osm', delete=False) as fd: - fname = fd.name - fd.write("\n") - fd.write('\n') - fd.write('\t\n') - - for obj in world.osm2pgsql: - write_osm_obj(fd, obj) - - fd.write('\n') - - logger.debug( "Filename: %s" % fname) - - cmd = [os.path.join(world.config.source_dir, 'utils', 'setup.php')] - cmd.extend(['--osm-file', fname, '--import-data','--osm2pgsql-cache', '300']) - proc = subprocess.Popen(cmd, cwd=world.config.source_dir, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - (outp, outerr) = proc.communicate() - assert (proc.returncode == 0), "OSM data import failed:\n%s\n%s\n" % (outp, outerr) - - ### reintroduce the triggers/indexes we've lost by having osm2pgsql set up place again - cur = world.conn.cursor() - cur.execute("""CREATE TRIGGER place_before_delete BEFORE DELETE ON place - FOR EACH ROW EXECUTE PROCEDURE place_delete()""") - cur.execute("""CREATE TRIGGER place_before_insert BEFORE INSERT ON place - FOR EACH ROW EXECUTE PROCEDURE place_insert()""") - cur.execute("""CREATE UNIQUE INDEX idx_place_osm_unique on place using btree(osm_id,osm_type,class,type)""") - world.conn.commit() - - - os.remove(fname) - world.osm2pgsql = [] - -actiontypes = { 'C' : 'create', 'M' : 'modify', 'D' : 'delete' } - -@step(u'updating osm data') -def osm2pgsql_update_place(step): - """Creates an osc file from the previously defined data and imports it - into the database. - """ - world.run_nominatim_script('setup', 'create-functions', 'create-partition-functions') - cur = world.conn.cursor() - cur.execute("""insert into placex (osm_type, osm_id, class, type, name, admin_level, - housenumber, street, addr_place, isin, postcode, country_code, extratags, - geometry) select * from place""") - world.conn.commit() - world.run_nominatim_script('setup', 'index', 'index-noanalyse') - world.run_nominatim_script('setup', 'create-functions', 'create-partition-functions', 'enable-diff-updates') - - with tempfile.NamedTemporaryFile(dir='/tmp', suffix='.osc', delete=False) as fd: - fname = fd.name - fd.write("\n") - fd.write('\n') - - for obj in world.osm2pgsql: - fd.write('<%s>\n' % (actiontypes[obj['action']], )) - write_osm_obj(fd, obj) - fd.write('\n' % (actiontypes[obj['action']], )) - - fd.write('\n') - - logger.debug( "Filename: %s" % fname) - - cmd = [os.path.join(world.config.source_dir, 'utils', 'update.php')] - cmd.extend(['--import-diff', fname]) - proc = subprocess.Popen(cmd, cwd=world.config.source_dir, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - (outp, outerr) = proc.communicate() - assert (proc.returncode == 0), "OSM data update failed:\n%s\n%s\n" % (outp, outerr) - - os.remove(fname) - world.osm2pgsql = [] diff --git a/tests/steps/terrain.py b/tests/steps/terrain.py deleted file mode 100644 index 80beebd5..00000000 --- a/tests/steps/terrain.py +++ /dev/null @@ -1,255 +0,0 @@ -from lettuce import * -from nose.tools import * -import logging -import os -import subprocess -import psycopg2 -import re -from haversine import haversine -from shapely.wkt import loads as wkt_load -from shapely.ops import linemerge - -logger = logging.getLogger(__name__) - -class NominatimConfig: - - def __init__(self): - # logging setup - loglevel = getattr(logging, os.environ.get('LOGLEVEL','info').upper()) - if 'LOGFILE' in os.environ: - logging.basicConfig(filename=os.environ.get('LOGFILE','run.log'), - level=loglevel) - else: - logging.basicConfig(level=loglevel) - # Nominatim test setup - self.base_url = os.environ.get('NOMINATIM_SERVER', 'http://localhost/nominatim') - self.source_dir = os.path.abspath(os.environ.get('NOMINATIM_DIR', '../build')) - self.template_db = os.environ.get('TEMPLATE_DB', 'test_template_nominatim') - self.test_db = os.environ.get('TEST_DB', 'test_nominatim') - self.local_settings_file = os.environ.get('NOMINATIM_SETTINGS', '/tmp/nominatim_settings.php') - self.reuse_template = 'NOMINATIM_REMOVE_TEMPLATE' not in os.environ - self.keep_scenario_db = 'NOMINATIM_KEEP_SCENARIO_DB' in os.environ - os.environ['NOMINATIM_SETTINGS'] = '/tmp/nominatim_settings.php' - - scriptpath = os.path.dirname(os.path.abspath(__file__)) - self.scene_path = os.environ.get('SCENE_PATH', - os.path.join(scriptpath, '..', 'scenes', 'data')) - - - def __str__(self): - return 'Server URL: %s\nSource dir: %s\n' % (self.base_url, self.source_dir) - -world.config = NominatimConfig() - -@world.absorb -def write_nominatim_config(dbname): - f = open(world.config.local_settings_file, 'w') - f.write("[:class]. - """ - oid = oid.strip() - if oid == 'None': - return None, None, None - osmtype = oid[0] - assert_in(osmtype, ('R','N','W')) - if ':' in oid: - osmid, cls = oid[1:].split(':') - return (osmtype, int(osmid), cls) - else: - return (osmtype, int(oid[1:]), None) - -@world.absorb -def get_placeid(oid): - """ Tries to retrive the place_id for a unique identifier. """ - if oid[0].isdigit(): - return int(oid) - - osmtype, osmid, cls = world.split_id(oid) - if osmtype is None: - return None - cur = world.conn.cursor() - if cls is None: - q = 'SELECT place_id FROM placex where osm_type = %s and osm_id = %s' - params = (osmtype, osmid) - else: - q = 'SELECT place_id FROM placex where osm_type = %s and osm_id = %s and class = %s' - params = (osmtype, osmid, cls) - cur.execute(q, params) - assert_equals(cur.rowcount, 1, "%d rows found for place %s" % (cur.rowcount, oid)) - return cur.fetchone()[0] - - -@world.absorb -def match_geometry(coord, matchstring): - m = re.match(r'([-0-9.]+),\s*([-0-9.]+)\s*(?:\+-([0-9.]+)([a-z]+)?)?', matchstring) - assert_is_not_none(m, "Invalid match string") - - logger.debug("Distmatch: %s/%s %s %s" % (m.group(1), m.group(2), m.group(3), m.group(4) )) - dist = haversine(coord, (float(m.group(1)), float(m.group(2)))) - - if m.group(3) is not None: - expdist = float(m.group(3)) - if m.group(4) is not None: - if m.group(4) == 'm': - expdist = expdist/1000 - elif m.group(4) == 'km': - pass - else: - raise Exception("Unknown unit '%s' in geometry match" % (m.group(4), )) - else: - expdist = 0 - - logger.debug("Distances expected: %f, got: %f" % (expdist, dist)) - assert dist <= expdist, "Geometry too far away, expected: %f, got: %f" % (expdist, dist) - -@world.absorb -def print_statement(element): - print '\n\n\n'+str(element)+'\n\n\n' - - -@world.absorb -def db_dump_table(table): - cur = world.conn.cursor() - cur.execute('SELECT * FROM %s' % table) - print '\n\n\n<<<<<<< BEGIN OF TABLE DUMP %s' % table - for res in cur: - print res - print '<<<<<<< END OF TABLE DUMP %s\n\n\n' % table - -@world.absorb -def db_drop_database(name): - conn = psycopg2.connect(database='postgres') - conn.set_isolation_level(0) - cur = conn.cursor() - cur.execute('DROP DATABASE IF EXISTS %s' % (name, )) - conn.close() - - -world.is_template_set_up = False - -@world.absorb -def db_template_setup(): - """ Set up a template database, containing all tables - but not yet any functions. - """ - if world.is_template_set_up: - return - - world.is_template_set_up = True - world.write_nominatim_config(world.config.template_db) - if world.config.reuse_template: - # check that the template is there - conn = psycopg2.connect(database='postgres') - cur = conn.cursor() - cur.execute('select count(*) from pg_database where datname = %s', - (world.config.template_db,)) - if cur.fetchone()[0] == 1: - return - else: - # just in case... make sure a previous table has been dropped - world.db_drop_database(world.config.template_db) - # call the first part of database setup - world.run_nominatim_script('setup', 'create-db', 'setup-db') - # remove external data to speed up indexing for tests - conn = psycopg2.connect(database=world.config.template_db) - psycopg2.extras.register_hstore(conn, globally=False, unicode=True) - cur = conn.cursor() - for table in ('gb_postcode', 'us_postcode'): - cur.execute("select * from pg_tables where tablename = '%s'" % (table, )) - if cur.rowcount > 0: - cur.execute('TRUNCATE TABLE %s' % (table,)) - conn.commit() - conn.close() - # execute osm2pgsql on an empty file to get the right tables - osm2pgsql = os.path.join(world.config.source_dir, 'osm2pgsql', 'osm2pgsql') - proc = subprocess.Popen([osm2pgsql, '-lsc', '-r', 'xml', '-O', 'gazetteer', '-d', world.config.template_db, '-'], - cwd=world.config.source_dir, stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - [outstr, errstr] = proc.communicate(input='') - logger.debug("running osm2pgsql for template: %s\n%s\n%s" % (osm2pgsql, outstr, errstr)) - world.run_nominatim_script('setup', 'create-functions', 'create-tables', 'create-partition-tables', 'create-partition-functions', 'load-data', 'create-search-indices') - - -# Leave the table around so it can be reused again after a non-reuse test round. -#@after.all -def db_template_teardown(total): - """ Set up a template database, containing all tables - but not yet any functions. - """ - if world.is_template_set_up: - # remove template DB - if not world.config.reuse_template: - world.db_drop_database(world.config.template_db) - try: - os.remove(world.config.local_settings_file) - except OSError: - pass # ignore missing file - - -########################################################################## -# -# Data scene handling -# - -world.scenes = {} -world.current_scene = None - -@world.absorb -def load_scene(name): - if name in world.scenes: - world.current_scene = world.scenes[name] - else: - with open(os.path.join(world.config.scene_path, "%s.wkt" % name), 'r') as fd: - scene = {} - for line in fd: - if line.strip(): - obj, wkt = line.split('|', 2) - wkt = wkt.strip() - scene[obj.strip()] = wkt_load(wkt) - world.scenes[name] = scene - world.current_scene = scene - -@world.absorb -def get_scene_geometry(name): - if not ':' in name: - # Not a scene description - return None - - geoms = [] - for obj in name.split('+'): - oname = obj.strip() - if oname.startswith(':'): - geoms.append(world.current_scene[oname[1:]]) - else: - scene, obj = oname.split(':', 2) - oldscene = world.current_scene - world.load_scene(scene) - wkt = world.current_scene[obj] - world.current_scene = oldscene - geoms.append(wkt) - - if len(geoms) == 1: - return geoms[0] - else: - return linemerge(geoms)