Update README with project details and setup instructions; add data directories to .gitignore
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
using System.Numerics;
|
||||
using TheLivingWorld.Core.Geo;
|
||||
using TheLivingWorld.Core.Worlds;
|
||||
|
||||
namespace TheLivingWorld.Tests;
|
||||
|
||||
public class LocalProjectionTests
|
||||
{
|
||||
// Robert Lee, Texas - the town the first worlds were generated around.
|
||||
private static readonly GeoPoint Origin = new(31.8966010, -100.4858591);
|
||||
|
||||
[Fact]
|
||||
public void Origin_projects_to_world_zero()
|
||||
{
|
||||
var projection = new LocalProjection(Origin);
|
||||
|
||||
var projected = projection.Project(Origin);
|
||||
|
||||
Assert.Equal(0f, projected.X, 3);
|
||||
Assert.Equal(0f, projected.Y, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_kilometre_north_lands_one_kilometre_up()
|
||||
{
|
||||
var projection = new LocalProjection(Origin);
|
||||
var north = new GeoPoint(Origin.Latitude + 1000.0 / LocalProjection.MetersPerDegreeLatitude, Origin.Longitude);
|
||||
|
||||
var projected = projection.Project(north);
|
||||
|
||||
Assert.Equal(0f, projected.X, 2);
|
||||
Assert.Equal(1000f, projected.Y, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unproject_reverses_project()
|
||||
{
|
||||
var projection = new LocalProjection(Origin);
|
||||
var point = new Vector2(-4321.5f, 6789.25f);
|
||||
|
||||
var roundTripped = projection.Project(projection.Unproject(point));
|
||||
|
||||
Assert.Equal(point.X, roundTripped.X, 1);
|
||||
Assert.Equal(point.Y, roundTripped.Y, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_coordinates_outside_the_globe()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new LocalProjection(new GeoPoint(91, 0)));
|
||||
}
|
||||
}
|
||||
|
||||
public class GeoBoundsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(31.8966010, -100.4858591, 10_000)]
|
||||
[InlineData(60.0, 30.3, 20_000)]
|
||||
[InlineData(-33.87, 151.21, 5_000)]
|
||||
public void FromCenter_produces_a_square_of_the_requested_size(double lat, double lon, double sizeMeters)
|
||||
{
|
||||
var center = new GeoPoint(lat, lon);
|
||||
var bounds = GeoBounds.FromCenter(center, sizeMeters);
|
||||
var projection = new LocalProjection(center);
|
||||
|
||||
var southWest = projection.Project(bounds.South, bounds.West);
|
||||
var northEast = projection.Project(bounds.North, bounds.East);
|
||||
|
||||
// Half a metre of slack over a 20 km span is the float rounding in the projection.
|
||||
Assert.Equal(sizeMeters, northEast.X - southWest.X, 0.5);
|
||||
Assert.Equal(sizeMeters, northEast.Y - southWest.Y, 0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overpass_bbox_is_south_west_north_east()
|
||||
{
|
||||
var bounds = new GeoBounds(South: 1, West: 2, North: 3, East: 4);
|
||||
|
||||
Assert.Equal("1.0000000,2.0000000,3.0000000,4.0000000", bounds.ToOverpassBbox());
|
||||
}
|
||||
}
|
||||
|
||||
public class ChunkGridTests
|
||||
{
|
||||
[Fact]
|
||||
public void Covers_the_whole_world_square()
|
||||
{
|
||||
var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512);
|
||||
|
||||
Assert.Equal(4, grid.CountX);
|
||||
Assert.Equal(4, grid.CountY);
|
||||
Assert.Equal(-1024f, grid.MinX);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rounds_the_count_up_when_the_size_does_not_divide_evenly()
|
||||
{
|
||||
var grid = new ChunkGrid(worldSizeMeters: 10_000, chunkSizeMeters: 512);
|
||||
|
||||
Assert.Equal(20, grid.CountX);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Maps_points_to_the_chunk_containing_them()
|
||||
{
|
||||
var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512);
|
||||
|
||||
Assert.Equal(new ChunkCoord(0, 0), grid.CoordOf(new Vector2(-1024, -1024)));
|
||||
Assert.Equal(new ChunkCoord(2, 2), grid.CoordOf(new Vector2(1, 1)));
|
||||
Assert.Equal(new ChunkCoord(3, 3), grid.CoordOf(new Vector2(1023, 1023)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clamps_points_that_fall_outside_the_world()
|
||||
{
|
||||
var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512);
|
||||
|
||||
Assert.Equal(new ChunkCoord(0, 0), grid.CoordOf(new Vector2(-99_999, -99_999)));
|
||||
Assert.Equal(new ChunkCoord(3, 3), grid.CoordOf(new Vector2(99_999, 99_999)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chunk_bounds_line_up_with_the_grid()
|
||||
{
|
||||
var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512);
|
||||
|
||||
var bounds = grid.BoundsOf(new ChunkCoord(1, 2));
|
||||
|
||||
Assert.Equal(-512f, bounds.MinX);
|
||||
Assert.Equal(0f, bounds.MinY);
|
||||
Assert.Equal(0f, bounds.MaxX);
|
||||
Assert.Equal(512f, bounds.MaxY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Numerics;
|
||||
using TheLivingWorld.Core.Geo;
|
||||
using TheLivingWorld.Osm.Import;
|
||||
|
||||
namespace TheLivingWorld.Tests;
|
||||
|
||||
public class GeometryClipperTests
|
||||
{
|
||||
private static readonly RectBounds Unit = new(0, 0, 100, 100);
|
||||
|
||||
[Fact]
|
||||
public void Leaves_a_polygon_that_is_already_inside_alone()
|
||||
{
|
||||
Vector2[] square = [new(10, 10), new(90, 10), new(90, 90), new(10, 90)];
|
||||
|
||||
var clipped = GeometryClipper.ClipPolygon(square, Unit);
|
||||
|
||||
Assert.NotNull(clipped);
|
||||
Assert.Equal(4, clipped.Length);
|
||||
Assert.Equal(Polygons.Area(square), Polygons.Area(clipped), 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trims_a_polygon_that_hangs_over_the_edge()
|
||||
{
|
||||
Vector2[] overhanging = [new(50, 50), new(200, 50), new(200, 90), new(50, 90)];
|
||||
|
||||
var clipped = GeometryClipper.ClipPolygon(overhanging, Unit);
|
||||
|
||||
Assert.NotNull(clipped);
|
||||
Assert.All(clipped, point => Assert.InRange(point.X, 0, 100));
|
||||
Assert.Equal(50f * 40f, Polygons.Area(clipped), 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drops_a_polygon_that_misses_the_box_entirely()
|
||||
{
|
||||
Vector2[] elsewhere = [new(500, 500), new(600, 500), new(600, 600)];
|
||||
|
||||
Assert.Null(GeometryClipper.ClipPolygon(elsewhere, Unit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keeps_the_part_of_a_line_that_crosses_the_box()
|
||||
{
|
||||
Vector2[] line = [new(-50, 50), new(150, 50)];
|
||||
|
||||
var runs = GeometryClipper.ClipPolyline(line, Unit);
|
||||
|
||||
var run = Assert.Single(runs);
|
||||
Assert.Equal(new Vector2(0, 50), run[0]);
|
||||
Assert.Equal(new Vector2(100, 50), run[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Splits_a_line_that_leaves_and_comes_back()
|
||||
{
|
||||
Vector2[] line = [new(10, 50), new(150, 50), new(150, 20), new(10, 20)];
|
||||
|
||||
var runs = GeometryClipper.ClipPolyline(line, Unit);
|
||||
|
||||
Assert.Equal(2, runs.Count);
|
||||
Assert.All(runs, run => Assert.All(run, point => Assert.InRange(point.X, 0, 100)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drops_a_line_that_never_touches_the_box()
|
||||
{
|
||||
Vector2[] line = [new(-50, -50), new(-10, -20)];
|
||||
|
||||
Assert.Empty(GeometryClipper.ClipPolyline(line, Unit));
|
||||
}
|
||||
}
|
||||
|
||||
public class PolygonsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Computes_the_area_of_a_square()
|
||||
{
|
||||
Vector2[] square = [new(0, 0), new(10, 0), new(10, 10), new(0, 10)];
|
||||
|
||||
Assert.Equal(100f, Polygons.Area(square));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detects_containment()
|
||||
{
|
||||
Vector2[] square = [new(0, 0), new(10, 0), new(10, 10), new(0, 10)];
|
||||
|
||||
Assert.True(Polygons.Contains(square, new Vector2(5, 5)));
|
||||
Assert.False(Polygons.Contains(square, new Vector2(15, 5)));
|
||||
}
|
||||
}
|
||||
|
||||
public class RingAssemblerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Joins_fragments_into_one_ring_whatever_their_order_or_direction()
|
||||
{
|
||||
// A 10x10 square split into three fragments; the middle one runs backwards.
|
||||
List<IReadOnlyList<Vector2>> fragments =
|
||||
[
|
||||
[new Vector2(10, 10), new Vector2(0, 10), new Vector2(0, 0)],
|
||||
[new Vector2(0, 0), new Vector2(10, 0)],
|
||||
[new Vector2(10, 10), new Vector2(10, 0)],
|
||||
];
|
||||
|
||||
var rings = RingAssembler.Assemble(fragments);
|
||||
|
||||
var ring = Assert.Single(rings);
|
||||
Assert.Equal(4, ring.Length);
|
||||
Assert.Equal(100f, Polygons.Area(ring));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepts_a_way_that_is_already_closed()
|
||||
{
|
||||
List<IReadOnlyList<Vector2>> fragments =
|
||||
[
|
||||
[new Vector2(0, 0), new Vector2(10, 0), new Vector2(10, 10), new Vector2(0, 10), new Vector2(0, 0)],
|
||||
];
|
||||
|
||||
var ring = Assert.Single(RingAssembler.Assemble(fragments));
|
||||
|
||||
// The repeated closing vertex is dropped: a ring is stored open.
|
||||
Assert.Equal(4, ring.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drops_fragments_that_never_close()
|
||||
{
|
||||
List<IReadOnlyList<Vector2>> fragments =
|
||||
[
|
||||
[new Vector2(0, 0), new Vector2(10, 0)],
|
||||
[new Vector2(10, 0), new Vector2(10, 10)],
|
||||
];
|
||||
|
||||
Assert.Empty(RingAssembler.Assemble(fragments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Separates_two_independent_rings()
|
||||
{
|
||||
List<IReadOnlyList<Vector2>> fragments =
|
||||
[
|
||||
[new Vector2(0, 0), new Vector2(10, 0), new Vector2(10, 10), new Vector2(0, 10), new Vector2(0, 0)],
|
||||
[new Vector2(50, 50), new Vector2(60, 50), new Vector2(60, 60), new Vector2(50, 50)],
|
||||
];
|
||||
|
||||
Assert.Equal(2, RingAssembler.Assemble(fragments).Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, string> { ["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<OsmWorldBuilder>.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<OsmWorldBuilder>.Instance).Populate(world, elements);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ExportedChunk> 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<string, string> tags, Vector2[] metres) => new()
|
||||
{
|
||||
Type = "way",
|
||||
Id = id,
|
||||
Tags = tags,
|
||||
Geometry = Metres(metres),
|
||||
};
|
||||
|
||||
/// <summary>Turns world-metre offsets from the origin back into the lat/lon pairs Overpass would send.</summary>
|
||||
private static List<OverpassNode> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TheLivingWorld.Core.Geo;
|
||||
using TheLivingWorld.Osm;
|
||||
using TheLivingWorld.Osm.Overpass;
|
||||
|
||||
namespace TheLivingWorld.Tests;
|
||||
|
||||
public class OverpassClientTests : IDisposable
|
||||
{
|
||||
private static readonly GeoBounds Bounds = GeoBounds.FromCenter(new GeoPoint(31.8966010, -100.4858591), 1000);
|
||||
|
||||
private readonly string _cacheDirectory =
|
||||
Path.Combine(Path.GetTempPath(), $"tlw-osm-cache-{Guid.NewGuid():n}");
|
||||
|
||||
[Fact]
|
||||
public async Task Caches_the_response_and_serves_it_again_without_a_second_request()
|
||||
{
|
||||
var handler = new StubHandler((_, _) => Json("""{"elements":[]}"""));
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var first = await client.FetchAsync(Bounds, forceRefresh: false);
|
||||
var second = await client.FetchAsync(Bounds, forceRefresh: false);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(1, handler.Requests);
|
||||
Assert.Equal("""{"elements":[]}""", await File.ReadAllTextAsync(first));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refetches_when_asked_to_refresh()
|
||||
{
|
||||
var handler = new StubHandler((_, _) => Json("""{"elements":[]}"""));
|
||||
var client = CreateClient(handler);
|
||||
|
||||
await client.FetchAsync(Bounds, forceRefresh: false);
|
||||
await client.FetchAsync(Bounds, forceRefresh: true);
|
||||
|
||||
Assert.Equal(2, handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Falls_back_to_the_next_mirror_after_a_retryable_failure()
|
||||
{
|
||||
var handler = new StubHandler((request, _) => request.RequestUri!.Host == "first.example"
|
||||
? new HttpResponseMessage(HttpStatusCode.TooManyRequests) { Content = new StringContent("busy") }
|
||||
: Json("""{"elements":[]}"""));
|
||||
|
||||
var client = CreateClient(handler, "https://first.example/api", "https://second.example/api");
|
||||
|
||||
var path = await client.FetchAsync(Bounds, forceRefresh: false);
|
||||
|
||||
Assert.True(File.Exists(path));
|
||||
Assert.Equal(2, handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_an_html_error_page_served_under_a_200()
|
||||
{
|
||||
// An overloaded Overpass instance answers "the server is probably too busy" as HTML with status 200.
|
||||
var handler = new StubHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("<html><body>too busy</body></html>", System.Text.Encoding.UTF8, "text/html"),
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var failure = await Assert.ThrowsAsync<OverpassException>(() => client.FetchAsync(Bounds, forceRefresh: false));
|
||||
|
||||
Assert.Contains("text/html", failure.Message);
|
||||
Assert.False(Directory.EnumerateFiles(_cacheDirectory, "*.json").Any());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reports_the_last_error_when_every_mirror_fails()
|
||||
{
|
||||
var handler = new StubHandler((_, _) => new HttpResponseMessage(HttpStatusCode.BadGateway)
|
||||
{
|
||||
Content = new StringContent("<p>gateway is down</p>"),
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var failure = await Assert.ThrowsAsync<OverpassException>(() => client.FetchAsync(Bounds, forceRefresh: false));
|
||||
|
||||
Assert.Contains("gateway is down", failure.Message);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private OverpassClient CreateClient(StubHandler handler, params string[] endpoints)
|
||||
{
|
||||
var options = new OsmOptions
|
||||
{
|
||||
CacheDirectory = _cacheDirectory,
|
||||
MaxAttemptsPerEndpoint = 1,
|
||||
RequestTimeoutSeconds = 5,
|
||||
};
|
||||
|
||||
if (endpoints.Length > 0) options.Endpoints = endpoints;
|
||||
else options.Endpoints = ["https://only.example/api"];
|
||||
|
||||
return new OverpassClient(
|
||||
new HttpClient(handler),
|
||||
Options.Create(options),
|
||||
NullLogger<OverpassClient>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_cacheDirectory)) Directory.Delete(_cacheDirectory, recursive: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public int Requests { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests++;
|
||||
return Task.FromResult(respond(request, cancellationToken));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using TheLivingWorld.Core.Ecs;
|
||||
using TheLivingWorld.Osm.Import;
|
||||
using TheLivingWorld.Osm.Overpass;
|
||||
|
||||
namespace TheLivingWorld.Tests;
|
||||
|
||||
public class TagInterpreterTests
|
||||
{
|
||||
private static OverpassElement Way(params string[] tags)
|
||||
{
|
||||
var map = new Dictionary<string, string>();
|
||||
for (var i = 0; i < tags.Length; i += 2)
|
||||
map[tags[i]] = tags[i + 1];
|
||||
|
||||
return new OverpassElement { Type = "way", Id = 1, Tags = map };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reads_an_explicit_height_including_its_unit()
|
||||
{
|
||||
var building = TagInterpreter.ReadBuilding(Way("building", "yes", "height", "12.5 m"));
|
||||
|
||||
Assert.Equal(12.5f, building.HeightMeters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Derives_height_from_levels_when_none_is_tagged()
|
||||
{
|
||||
var building = TagInterpreter.ReadBuilding(Way("building", "apartments", "building:levels", "4"));
|
||||
|
||||
Assert.Equal(4, building.Levels);
|
||||
Assert.Equal(12f, building.HeightMeters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Falls_back_to_a_height_that_suits_the_kind()
|
||||
{
|
||||
var shed = TagInterpreter.ReadBuilding(Way("building", "shed"));
|
||||
var house = TagInterpreter.ReadBuilding(Way("building", "house"));
|
||||
|
||||
Assert.Equal(BuildingKind.Shed, shed.Kind);
|
||||
Assert.True(shed.HeightMeters < house.HeightMeters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ignores_a_height_that_is_not_a_number()
|
||||
{
|
||||
var building = TagInterpreter.ReadBuilding(Way("building", "house", "height", "tall"));
|
||||
|
||||
Assert.Equal(5f, building.HeightMeters);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("motorway", RoadClass.Motorway)]
|
||||
[InlineData("motorway_link", RoadClass.Motorway)]
|
||||
[InlineData("residential", RoadClass.Residential)]
|
||||
[InlineData("living_street", RoadClass.LivingStreet)]
|
||||
[InlineData("nonsense", RoadClass.Unknown)]
|
||||
public void Maps_highway_values_to_road_classes(string highway, RoadClass expected)
|
||||
{
|
||||
Assert.Equal(expected, TagInterpreter.ReadRoad(Way("highway", highway)).Class);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefers_a_tagged_width_over_lane_count()
|
||||
{
|
||||
var road = TagInterpreter.ReadRoad(Way("highway", "residential", "lanes", "2", "width", "9"));
|
||||
|
||||
Assert.Equal(9f, road.WidthMeters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Derives_width_from_lanes_when_no_width_is_tagged()
|
||||
{
|
||||
var road = TagInterpreter.ReadRoad(Way("highway", "residential", "lanes", "4"));
|
||||
|
||||
Assert.Equal(4, road.Lanes);
|
||||
Assert.Equal(13.6f, road.WidthMeters, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reads_road_flags()
|
||||
{
|
||||
var road = TagInterpreter.ReadRoad(Way("highway", "track", "bridge", "yes", "surface", "gravel"));
|
||||
|
||||
Assert.True(road.Flags.HasFlag(RoadFlags.Bridge));
|
||||
Assert.True(road.Flags.HasFlag(RoadFlags.Unpaved));
|
||||
Assert.False(road.Flags.HasFlag(RoadFlags.Tunnel));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Treats_railways_without_a_highway_tag_as_rail()
|
||||
{
|
||||
Assert.True(TagInterpreter.IsRoad(Way("railway", "rail")));
|
||||
Assert.Equal(RoadClass.Railway, TagInterpreter.ReadRoad(Way("railway", "rail")).Class);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("river", WaterKind.River, 12f)]
|
||||
[InlineData("stream", WaterKind.Stream, 3f)]
|
||||
public void Reads_watercourses_with_a_default_width(string waterway, WaterKind kind, float width)
|
||||
{
|
||||
var water = TagInterpreter.ReadWater(Way("waterway", waterway));
|
||||
|
||||
Assert.NotNull(water);
|
||||
Assert.Equal(kind, water.Value.Kind);
|
||||
Assert.Equal(width, water.Value.WidthMeters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reads_a_lake_as_a_water_body()
|
||||
{
|
||||
var water = TagInterpreter.ReadWater(Way("natural", "water", "water", "lake"));
|
||||
|
||||
Assert.Equal(WaterKind.Lake, water!.Value.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Does_not_treat_a_dam_as_water()
|
||||
{
|
||||
Assert.Null(TagInterpreter.ReadWater(Way("waterway", "dam")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("landuse", "forest", AreaKind.Forest)]
|
||||
[InlineData("natural", "wood", AreaKind.Forest)]
|
||||
[InlineData("leisure", "park", AreaKind.Park)]
|
||||
[InlineData("amenity", "parking", AreaKind.Parking)]
|
||||
public void Maps_land_cover_tags_to_area_kinds(string key, string value, AreaKind expected)
|
||||
{
|
||||
Assert.Equal(expected, TagInterpreter.ReadAreaKind(Way(key, value)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_no_area_kind_for_an_unrelated_element()
|
||||
{
|
||||
Assert.Null(TagInterpreter.ReadAreaKind(Way("highway", "residential")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("12", 12.0)]
|
||||
[InlineData("12 m", 12.0)]
|
||||
[InlineData("3.5", 3.5)]
|
||||
[InlineData("-2", -2.0)]
|
||||
public void Parses_numeric_prefixes(string raw, double expected)
|
||||
{
|
||||
Assert.True(TagInterpreter.TryParseLeadingNumber(raw, out var value));
|
||||
Assert.Equal(expected, value, 5);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("tall")]
|
||||
[InlineData(null)]
|
||||
public void Rejects_values_without_a_number(string? raw)
|
||||
{
|
||||
Assert.False(TagInterpreter.TryParseLeadingNumber(raw, out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\TheLivingWorld.Core\TheLivingWorld.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\TheLivingWorld.Osm\TheLivingWorld.Osm.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user