Update README with project details and setup instructions; add data directories to .gitignore

This commit is contained in:
Leonid Pershin
2026-08-16 17:09:16 +03:00
parent 9d09d6e97f
commit 8460921bfa
73 changed files with 6978 additions and 1 deletions
+134
View File
@@ -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);
}
}
+152
View File
@@ -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>