using System.Numerics; using Microsoft.Extensions.Logging.Abstractions; using TheLivingWorld.Core.Ecs; using TheLivingWorld.Core.Export; using TheLivingWorld.Core.Geo; using TheLivingWorld.Core.Systems; using TheLivingWorld.Core.Worlds; using TheLivingWorld.Osm.Import; using TheLivingWorld.Osm.Overpass; namespace TheLivingWorld.Tests; /// /// Drives the whole import path - Overpass elements in, chunk payloads out - against hand-built geometry, so /// the tests do not need a network call to exercise it. /// public class ImportPipelineTests { private const double WorldSizeMeters = 2048; private static readonly GeoPoint Origin = new(31.8966010, -100.4858591); [Fact] public void Imports_a_building_way_as_an_entity_with_a_footprint() { using var world = Build(WayWithGeometry( id: 42, tags: new() { ["building"] = "house", ["name"] = "Old Post Office" }, metres: Square(100, 100, 20, closed: true))); var chunk = Assert.Single(Export(world)).Chunk; var building = Assert.Single(chunk.Buildings); Assert.Equal(42, building.Id); Assert.Equal(BuildingKind.House, building.Kind); Assert.Equal("Old Post Office", building.Name); // Four corners, flattened to [x, y] pairs, with the repeated closing vertex dropped. Assert.Equal(8, building.Outline.Length); } [Fact] public void Places_features_in_the_chunk_that_contains_them() { using var world = Build( WayWithGeometry(1, new() { ["building"] = "yes" }, Square(-900, -900, 20, closed: true)), WayWithGeometry(2, new() { ["building"] = "yes" }, Square(900, 900, 20, closed: true))); var chunks = Export(world); Assert.Equal(2, chunks.Count); Assert.Contains(chunks, c => c.Coord == new ChunkCoord(0, 0)); Assert.Contains(chunks, c => c.Coord == new ChunkCoord(3, 3)); } [Fact] public void Imports_a_highway_as_a_road_with_a_width() { using var world = Build(WayWithGeometry( id: 7, tags: new() { ["highway"] = "residential", ["name"] = "Main Street" }, metres: [new Vector2(-200, 0), new Vector2(200, 0)])); var road = Assert.Single(Export(world)[0].Chunk.Roads); Assert.Equal(RoadClass.Residential, road.Class); Assert.Equal("Main Street", road.Name); Assert.Equal(6.5f, road.Width); } [Fact] public void Clips_geometry_that_reaches_beyond_the_world_square() { // A highway running far past the eastern edge of the generated square. using var world = Build(WayWithGeometry( id: 8, tags: new() { ["highway"] = "primary" }, metres: [new Vector2(0, 0), new Vector2(50_000, 0)])); var road = Assert.Single(Export(world)[0].Chunk.Roads); var maxX = road.Path.Where((_, index) => index % 2 == 0).Max(); Assert.True(maxX <= WorldSizeMeters / 2 + 0.5, $"Road reached {maxX} m, past the world edge."); } [Fact] public void Assembles_a_multipolygon_relation_and_keeps_its_holes() { // A forest with a clearing cut out of it, split into two member ways as OSM usually stores them. var outerWest = Metres([new(-300, -300), new(-300, 300), new(300, 300)]); var outerEast = Metres([new(300, 300), new(300, -300), new(-300, -300)]); var clearing = Metres([new(-50, -50), new(50, -50), new(50, 50), new(-50, 50), new(-50, -50)]); var relation = new OverpassElement { Type = "relation", Id = 99, Tags = new Dictionary { ["type"] = "multipolygon", ["landuse"] = "forest" }, Members = [ new OverpassMember { Type = "way", Ref = 1, Role = "outer", Geometry = outerWest }, new OverpassMember { Type = "way", Ref = 2, Role = "outer", Geometry = outerEast }, new OverpassMember { Type = "way", Ref = 3, Role = "inner", Geometry = clearing }, ], }; using var world = Build(relation); var area = Assert.Single(Export(world)[0].Chunk.Areas); Assert.Equal(AreaKind.Forest, area.Kind); Assert.Equal(4, area.Outline.Length / 2); Assert.NotNull(area.Holes); Assert.Single(area.Holes); } [Fact] public void Splits_water_into_bodies_and_watercourses() { using var world = Build( WayWithGeometry(10, new() { ["natural"] = "water", ["water"] = "pond" }, Square(0, 0, 60, closed: true)), WayWithGeometry(11, new() { ["waterway"] = "stream" }, [new Vector2(-400, -400), new Vector2(-300, -350)])); var water = Export(world).SelectMany(chunk => chunk.Chunk.Water).ToList(); var pond = Assert.Single(water, feature => feature.Kind == WaterKind.Pond); Assert.NotNull(pond.Outline); Assert.Null(pond.Path); var stream = Assert.Single(water, feature => feature.Kind == WaterKind.Stream); Assert.NotNull(stream.Path); Assert.Null(stream.Outline); Assert.Equal(3f, stream.Width); } [Fact] public void Counts_what_it_imported() { var stats = BuildWithStats( WayWithGeometry(1, new() { ["building"] = "yes" }, Square(0, 0, 20, closed: true)), WayWithGeometry(2, new() { ["highway"] = "service" }, [new Vector2(0, 0), new Vector2(50, 0)]), WayWithGeometry(3, new() { ["landuse"] = "meadow" }, Square(200, 200, 100, closed: true))); Assert.Equal(1, stats.Buildings); Assert.Equal(1, stats.Roads); Assert.Equal(1, stats.Areas); Assert.Equal(3, stats.Total); } [Fact] public void Chunk_bounds_stretch_to_cover_geometry_that_overhangs() { // A road whose centre sits in one chunk but which reaches well into its neighbours. using var world = Build(WayWithGeometry( id: 12, tags: new() { ["highway"] = "primary" }, metres: [new Vector2(-900, 0), new Vector2(900, 0)])); var exported = Assert.Single(Export(world)); var square = world.Grid.BoundsOf(exported.Coord); Assert.True(exported.Index.Bounds[0] < square.MinX); Assert.True(exported.Index.Bounds[2] > square.MaxX); } [Fact] public void Skips_elements_it_does_not_understand() { var stats = BuildWithStats( WayWithGeometry(1, new() { ["barrier"] = "fence" }, [new Vector2(0, 0), new Vector2(10, 0)]), WayWithGeometry(2, new() { ["waterway"] = "dam" }, [new Vector2(0, 0), new Vector2(10, 0)])); Assert.Equal(0, stats.Total); } private static GameWorld Build(params OverpassElement[] elements) { var world = NewWorld(); new OsmWorldBuilder(NullLogger.Instance).Populate(world, elements); WorldPipeline.CreateDefault().Run(world); return world; } private static WorldStats BuildWithStats(params OverpassElement[] elements) { using var world = NewWorld(); return new OsmWorldBuilder(NullLogger.Instance).Populate(world, elements); } private static IReadOnlyList Export(GameWorld world) => new ChunkExporter().Export(world); private static GameWorld NewWorld() => new(new WorldMetadata { Id = "test", Name = "Test", Origin = Origin, SizeMeters = WorldSizeMeters, Bounds = GeoBounds.FromCenter(Origin, WorldSizeMeters), ChunkSizeMeters = 512, ChunkCountX = 4, ChunkCountY = 4, GeneratedAt = DateTimeOffset.UnixEpoch, }); private static OverpassElement WayWithGeometry(long id, Dictionary tags, Vector2[] metres) => new() { Type = "way", Id = id, Tags = tags, Geometry = Metres(metres), }; /// Turns world-metre offsets from the origin back into the lat/lon pairs Overpass would send. private static List Metres(Vector2[] points) { var projection = new LocalProjection(Origin); return [.. points.Select(point => { var geo = projection.Unproject(point); return new OverpassNode { Lat = geo.Latitude, Lon = geo.Longitude }; })]; } private static Vector2[] Square(float centerX, float centerY, float size, bool closed) { var half = size / 2; Vector2[] corners = [ new(centerX - half, centerY - half), new(centerX + half, centerY - half), new(centerX + half, centerY + half), new(centerX - half, centerY + half), ]; return closed ? [.. corners, corners[0]] : corners; } }