diff --git a/src/WayfarerMobile.Core/Helpers/TripSegmentGeometryParser.cs b/src/WayfarerMobile.Core/Helpers/TripSegmentGeometryParser.cs new file mode 100644 index 00000000..1487e364 --- /dev/null +++ b/src/WayfarerMobile.Core/Helpers/TripSegmentGeometryParser.cs @@ -0,0 +1,197 @@ +using System.Text.Json; + +namespace WayfarerMobile.Core.Helpers; + +public enum SegmentGeometryFailure +{ + Empty, + MalformedGeoJson, + UnsupportedGeoJsonType, + MalformedEncodedPolyline, + InvalidCoordinate, + InsufficientPoints +} + +public sealed record SegmentGeometryParseResult( + IReadOnlyList<(double Latitude, double Longitude)> Coordinates, + SegmentGeometryFailure? Failure) +{ + public bool IsSuccess => Failure is null; +} + +/// +/// Parses Segment transport geometry into validated geographic coordinates. +/// +public static class TripSegmentGeometryParser +{ + private static readonly IReadOnlyList<(double Latitude, double Longitude)> NoCoordinates = + Array.Empty<(double Latitude, double Longitude)>(); + + public static SegmentGeometryParseResult Parse(string? geometry) + { + if (string.IsNullOrWhiteSpace(geometry)) + return Failure(SegmentGeometryFailure.Empty); + + var detected = geometry.AsSpan().TrimStart(); + return detected[0] == '{' + ? ParseGeoJson(detected) + : ParseEncodedPolyline(geometry); + } + + private static SegmentGeometryParseResult ParseGeoJson(ReadOnlySpan geometry) + { + JsonDocument document; + try + { + document = JsonDocument.Parse(geometry.ToString()); + } + catch (JsonException) + { + return Failure(SegmentGeometryFailure.MalformedGeoJson); + } + + using (document) + { + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("type", out var type) || + type.ValueKind != JsonValueKind.String) + { + return Failure(SegmentGeometryFailure.MalformedGeoJson); + } + + if (type.GetString() != "LineString") + return Failure(SegmentGeometryFailure.UnsupportedGeoJsonType); + + if (!root.TryGetProperty("coordinates", out var coordinates) || + coordinates.ValueKind != JsonValueKind.Array) + { + return Failure(SegmentGeometryFailure.MalformedGeoJson); + } + + var result = new List<(double Latitude, double Longitude)>(); + foreach (var position in coordinates.EnumerateArray()) + { + if (position.ValueKind != JsonValueKind.Array || position.GetArrayLength() < 2) + return Failure(SegmentGeometryFailure.InvalidCoordinate); + + var ordinates = position.EnumerateArray(); + ordinates.MoveNext(); + var longitudeValue = ordinates.Current; + ordinates.MoveNext(); + var latitudeValue = ordinates.Current; + if (longitudeValue.ValueKind != JsonValueKind.Number || + latitudeValue.ValueKind != JsonValueKind.Number || + !longitudeValue.TryGetDouble(out var longitude) || + !latitudeValue.TryGetDouble(out var latitude) || + !IsValidCoordinate(latitude, longitude)) + { + return Failure(SegmentGeometryFailure.InvalidCoordinate); + } + + result.Add((latitude, longitude)); + } + + return result.Count < 2 + ? Failure(SegmentGeometryFailure.InsufficientPoints) + : Success(result); + } + } + + private static SegmentGeometryParseResult ParseEncodedPolyline(string geometry) + { + if (!IsStructurallyValidEncodedPolyline(geometry)) + return Failure(SegmentGeometryFailure.MalformedEncodedPolyline); + + var coordinates = PolylineDecoder.DecodeToTuples(geometry); + if (coordinates.Any(point => !IsValidCoordinate(point.Latitude, point.Longitude))) + return Failure(SegmentGeometryFailure.InvalidCoordinate); + + return coordinates.Count < 2 + ? Failure(SegmentGeometryFailure.InsufficientPoints) + : Success(coordinates); + } + + private static bool IsStructurallyValidEncodedPolyline(string encoded) + { + var index = 0; + long latitude = 0; + long longitude = 0; + + while (index < encoded.Length) + { + if (!TryReadComponent(encoded, ref index, out var latitudeDelta) || + !TryReadComponent(encoded, ref index, out var longitudeDelta)) + { + return false; + } + + try + { + latitude = checked(latitude + latitudeDelta); + longitude = checked(longitude + longitudeDelta); + } + catch (OverflowException) + { + return false; + } + + if (latitude is < -9_000_000 or > 9_000_000 || + longitude is < -18_000_000 or > 18_000_000) + { + return false; + } + } + + return true; + } + + private static bool TryReadComponent(string encoded, ref int index, out long delta) + { + ulong result = 0; + var shift = 0; + + while (index < encoded.Length) + { + var character = encoded[index++]; + if (character is < '?' or > '~') + { + delta = 0; + return false; + } + + var value = character - 63; + if (shift > 30 || (shift == 30 && (value & 0x1f) > 1)) + { + delta = 0; + return false; + } + + result |= (ulong)(value & 0x1f) << shift; + if (value < 0x20) + { + delta = (result & 1) == 0 + ? (long)(result >> 1) + : -(long)(result >> 1) - 1; + return true; + } + + shift += 5; + } + + delta = 0; + return false; + } + + private static bool IsValidCoordinate(double latitude, double longitude) => + double.IsFinite(latitude) && + double.IsFinite(longitude) && + latitude is >= -90 and <= 90 && + longitude is >= -180 and <= 180; + + private static SegmentGeometryParseResult Success( + IReadOnlyList<(double Latitude, double Longitude)> coordinates) => new(coordinates, null); + + private static SegmentGeometryParseResult Failure(SegmentGeometryFailure failure) => + new(NoCoordinates, failure); +} diff --git a/src/WayfarerMobile.Core/Navigation/TripNavigationGraphBuilder.cs b/src/WayfarerMobile.Core/Navigation/TripNavigationGraphBuilder.cs new file mode 100644 index 00000000..95cab577 --- /dev/null +++ b/src/WayfarerMobile.Core/Navigation/TripNavigationGraphBuilder.cs @@ -0,0 +1,68 @@ +using WayfarerMobile.Core.Helpers; +using WayfarerMobile.Core.Models; + +namespace WayfarerMobile.Core.Navigation; + +/// +/// Builds the local navigation graph from transport trip data. +/// +public static class TripNavigationGraphBuilder +{ + public static TripNavigationGraph Build( + TripDetails trip, + Action? geometryFailure = null) + { + var graph = new TripNavigationGraph { TripId = trip.Id }; + + foreach (var region in trip.Regions) + { + foreach (var place in region.Places) + { + graph.AddNode(new NavigationNode + { + Id = place.Id.ToString(), + Name = place.Name, + Latitude = place.Latitude, + Longitude = place.Longitude, + Type = NavigationNodeType.Place, + SortOrder = place.SortOrder, + Notes = place.Notes, + IconName = place.Icon + }); + } + } + + foreach (var segment in trip.Segments) + { + var edge = new NavigationEdge + { + FromNodeId = (segment.OriginId ?? Guid.Empty).ToString(), + ToNodeId = (segment.DestinationId ?? Guid.Empty).ToString(), + TransportMode = segment.TransportMode ?? "walking", + DistanceKm = segment.DistanceKm ?? 0, + DurationMinutes = (int)(segment.DurationMinutes ?? 0), + EdgeType = NavigationEdgeType.UserSegment + }; + + var parseResult = TripSegmentGeometryParser.Parse(segment.Geometry); + if (parseResult.IsSuccess) + { + edge.RouteGeometry = parseResult.Coordinates + .Select(point => new RoutePoint + { + Latitude = point.Latitude, + Longitude = point.Longitude + }) + .ToList(); + } + else if (parseResult.Failure != SegmentGeometryFailure.Empty) + { + geometryFailure?.Invoke(segment.Id, parseResult.Failure!.Value); + } + + graph.AddEdge(edge); + } + + return graph; + } +} diff --git a/src/WayfarerMobile/Services/TripLayerService.cs b/src/WayfarerMobile/Services/TripLayerService.cs index 43544718..d9c61004 100644 --- a/src/WayfarerMobile/Services/TripLayerService.cs +++ b/src/WayfarerMobile/Services/TripLayerService.cs @@ -210,44 +210,24 @@ public void UpdateTripSegments(WritableLayer layer, IEnumerable seg var segmentCount = 0; foreach (var segment in segmentList) { - _logger.LogDebug("Processing segment {Id}: Mode={Mode}, Geometry length={Length}", - segment.Id, segment.TransportMode ?? "null", - segment.Geometry?.Length ?? 0); - if (string.IsNullOrEmpty(segment.Geometry)) { _logger.LogWarning("Skipping segment {Id}: no geometry", segment.Id); continue; } - try + var parseResult = TripSegmentGeometryParser.Parse(segment.Geometry); + if (!parseResult.IsSuccess) { - // Parse geometry - could be GeoJSON LineString or encoded polyline - List<(double Latitude, double Longitude)> coordinates; - - if (segment.Geometry.TrimStart().StartsWith("{")) - { - // GeoJSON format: {"type":"LineString","coordinates":[[lon,lat],...]} - coordinates = ParseGeoJsonLineString(segment.Geometry); - _logger.LogDebug("Parsed GeoJSON segment {Id}: {PointCount} points", segment.Id, coordinates.Count); - } - else - { - // Encoded polyline format - var points = PolylineDecoder.Decode(segment.Geometry); - coordinates = points.Select(p => (p.Latitude, p.Longitude)).ToList(); - _logger.LogDebug("Decoded polyline segment {Id}: {PointCount} points", segment.Id, coordinates.Count); - } - - if (coordinates.Count < 2) - { - _logger.LogWarning("Skipping segment {Id}: only {Count} points after parsing", - segment.Id, coordinates.Count); - continue; - } + if (parseResult.Failure != SegmentGeometryFailure.Empty) + _logger.LogWarning("Skipping segment {SegmentId}: geometry failure {Failure}", segment.Id, parseResult.Failure); + continue; + } + try + { // Convert to map coordinates - var mapCoordinates = coordinates + var mapCoordinates = parseResult.Coordinates .Select(p => { var (x, y) = SphericalMercator.FromLonLat(p.Longitude, p.Latitude); @@ -559,43 +539,4 @@ public void ClearPlaceSelection(WritableLayer layer) #endregion - #region GeoJSON Parsing - - /// - /// Parses a GeoJSON LineString into coordinate pairs. - /// - /// GeoJSON string with type "LineString". - /// List of (Latitude, Longitude) tuples. - private static List<(double Latitude, double Longitude)> ParseGeoJsonLineString(string geoJson) - { - var result = new List<(double Latitude, double Longitude)>(); - - try - { - using var doc = System.Text.Json.JsonDocument.Parse(geoJson); - var root = doc.RootElement; - - // GeoJSON LineString format: { "type": "LineString", "coordinates": [[lon,lat], [lon,lat], ...] } - if (root.TryGetProperty("coordinates", out var coordinates)) - { - foreach (var point in coordinates.EnumerateArray()) - { - if (point.GetArrayLength() >= 2) - { - var lon = point[0].GetDouble(); - var lat = point[1].GetDouble(); - result.Add((lat, lon)); - } - } - } - } - catch - { - // Invalid GeoJSON, return empty list - } - - return result; - } - - #endregion } diff --git a/src/WayfarerMobile/Services/TripNavigationService.cs b/src/WayfarerMobile/Services/TripNavigationService.cs index fe555ddf..d4c9c6f9 100644 --- a/src/WayfarerMobile/Services/TripNavigationService.cs +++ b/src/WayfarerMobile/Services/TripNavigationService.cs @@ -634,53 +634,12 @@ public IEnumerable GetTripPlaces() /// private TripNavigationGraph BuildNavigationGraph(TripDetails trip) { - var graph = new TripNavigationGraph { TripId = trip.Id }; - - // Add all places as nodes - foreach (var region in trip.Regions) - { - foreach (var place in region.Places) - { - graph.AddNode(new NavigationNode - { - Id = place.Id.ToString(), - Name = place.Name, - Latitude = place.Latitude, - Longitude = place.Longitude, - Type = NavigationNodeType.Place, - SortOrder = place.SortOrder, - Notes = place.Notes, - IconName = place.Icon - }); - } - } - - // Add segments as edges - foreach (var segment in trip.Segments) - { - var edge = new NavigationEdge - { - FromNodeId = (segment.OriginId ?? Guid.Empty).ToString(), - ToNodeId = (segment.DestinationId ?? Guid.Empty).ToString(), - TransportMode = segment.TransportMode ?? "walking", - DistanceKm = segment.DistanceKm ?? 0, - DurationMinutes = (int)(segment.DurationMinutes ?? 0), - EdgeType = NavigationEdgeType.UserSegment - }; - - // Decode route geometry if available - if (!string.IsNullOrEmpty(segment.Geometry)) - { - edge.RouteGeometry = PolylineDecoder.Decode(segment.Geometry); - } - - graph.AddEdge(edge); - } - - // No fallback connections - if no segment exists, use direct route - // This matches old app behavior: honest navigation with bearing + distance - - return graph; + return TripNavigationGraphBuilder.Build( + trip, + (segmentId, failure) => _logger.LogWarning( + "Segment {SegmentId} geometry failure {Failure}", + segmentId, + failure)); } /// diff --git a/tests/WayfarerMobile.Tests/Unit/Helpers/TripSegmentGeometryParserTests.cs b/tests/WayfarerMobile.Tests/Unit/Helpers/TripSegmentGeometryParserTests.cs new file mode 100644 index 00000000..972d39ea --- /dev/null +++ b/tests/WayfarerMobile.Tests/Unit/Helpers/TripSegmentGeometryParserTests.cs @@ -0,0 +1,82 @@ +using WayfarerMobile.Core.Helpers; + +namespace WayfarerMobile.Tests.Unit.Helpers; + +public class TripSegmentGeometryParserTests +{ + [Fact] + public void Parse_ApiGeoJsonLineString_PreservesOrderedLongitudeLatitudeCoordinates() + { + const string geometry = """ + {"type":"LineString","coordinates":[[23.7275,37.9838],[23.7281,37.9844,42]]} + """; + + var result = TripSegmentGeometryParser.Parse(geometry); + + result.IsSuccess.Should().BeTrue(); + result.Failure.Should().BeNull(); + result.Coordinates.Should().BeEquivalentTo( + [(37.9838, 23.7275), (37.9844, 23.7281)], + options => options.WithStrictOrdering()); + } + + [Fact] + public void Parse_EncodedPolyline_PreservesExistingPrecisionAndOrder() + { + var result = TripSegmentGeometryParser.Parse("_p~iF~ps|U_ulLnnqC_mqNvxq`@"); + + result.IsSuccess.Should().BeTrue(); + result.Coordinates.Should().BeEquivalentTo( + [(38.5, -120.2), (40.7, -120.95), (43.252, -126.453)], + options => options.WithStrictOrdering()); + } + + [Theory] + [InlineData(null, SegmentGeometryFailure.Empty)] + [InlineData(" ", SegmentGeometryFailure.Empty)] + [InlineData("{not json", SegmentGeometryFailure.MalformedGeoJson)] + [InlineData("{\"Type\":\"LineString\",\"coordinates\":[[1,2],[3,4]]}", SegmentGeometryFailure.MalformedGeoJson)] + [InlineData("{\"type\":\"linestring\",\"coordinates\":[[1,2],[3,4]]}", SegmentGeometryFailure.UnsupportedGeoJsonType)] + [InlineData("{\"type\":\"Point\",\"coordinates\":[1,2]}", SegmentGeometryFailure.UnsupportedGeoJsonType)] + [InlineData("{\"type\":\"LineString\",\"coordinates\":null}", SegmentGeometryFailure.MalformedGeoJson)] + [InlineData("{\"type\":\"LineString\",\"coordinates\":[[1],[3,4]]}", SegmentGeometryFailure.InvalidCoordinate)] + [InlineData("{\"type\":\"LineString\",\"coordinates\":[[1,\"2\"],[3,4]]}", SegmentGeometryFailure.InvalidCoordinate)] + [InlineData("{\"type\":\"LineString\",\"coordinates\":[[1,2]]}", SegmentGeometryFailure.InsufficientPoints)] + [InlineData("{\"type\":\"LineString\",\"coordinates\":[[181,2],[3,4]]}", SegmentGeometryFailure.InvalidCoordinate)] + [InlineData("{\"type\":\"LineString\",\"coordinates\":[[1,91],[3,4]]}", SegmentGeometryFailure.InvalidCoordinate)] + [InlineData("{\"type\":\"LineString\",\"coordinates\":[[1e400,2],[3,4]]}", SegmentGeometryFailure.InvalidCoordinate)] + [InlineData("_p~iF", SegmentGeometryFailure.MalformedEncodedPolyline)] + [InlineData("_p~iF~ps|U\n", SegmentGeometryFailure.MalformedEncodedPolyline)] + [InlineData("~~~~~~~?", SegmentGeometryFailure.MalformedEncodedPolyline)] + [InlineData("_p~iF~ps|U", SegmentGeometryFailure.InsufficientPoints)] + public void Parse_InvalidGeometry_ReturnsBoundedFailure(string? geometry, SegmentGeometryFailure expected) + { + var result = TripSegmentGeometryParser.Parse(geometry); + + result.IsSuccess.Should().BeFalse(); + result.Failure.Should().Be(expected); + result.Coordinates.Should().BeEmpty(); + } + + [Fact] + public void Parse_MalformedGeoJson_DoesNotFallThroughToEncodedPolyline() + { + var result = TripSegmentGeometryParser.Parse(" {????????????"); + + result.Failure.Should().Be(SegmentGeometryFailure.MalformedGeoJson); + } + + [Fact] + public void Parse_PositiveNegativeAndAntimeridianValues_PreservesMeaningAndOrder() + { + const string geometry = """ + {"type":"LineString","coordinates":[[179.9999,-45.25],[-179.9998,45.5]]} + """; + + var result = TripSegmentGeometryParser.Parse(geometry); + + result.Coordinates.Should().BeEquivalentTo( + [(-45.25, 179.9999), (45.5, -179.9998)], + options => options.WithStrictOrdering()); + } +} diff --git a/tests/WayfarerMobile.Tests/Unit/Navigation/TripNavigationGraphBuilderTests.cs b/tests/WayfarerMobile.Tests/Unit/Navigation/TripNavigationGraphBuilderTests.cs new file mode 100644 index 00000000..7ceee265 --- /dev/null +++ b/tests/WayfarerMobile.Tests/Unit/Navigation/TripNavigationGraphBuilderTests.cs @@ -0,0 +1,75 @@ +using WayfarerMobile.Core.Helpers; +using WayfarerMobile.Core.Models; +using WayfarerMobile.Core.Navigation; + +namespace WayfarerMobile.Tests.Unit.Navigation; + +public class TripNavigationGraphBuilderTests +{ + [Fact] + public void Build_ApiGeoJsonSegment_RetainsEdgeWithExactRouteGeometry() + { + var (trip, fromId, toId) = CreateTrip( + "{\"type\":\"LineString\",\"coordinates\":[[23.7275,37.9838],[23.7281,37.9844]]}"); + + var graph = TripNavigationGraphBuilder.Build(trip); + + var edge = graph.GetEdgeBetween(fromId.ToString(), toId.ToString()); + edge.Should().NotBeNull(); + edge!.RouteGeometry.Should().BeEquivalentTo( + [ + new RoutePoint { Latitude = 37.9838, Longitude = 23.7275 }, + new RoutePoint { Latitude = 37.9844, Longitude = 23.7281 } + ], + options => options.WithStrictOrdering()); + + var corruptPoints = PolylineDecoder.Decode(trip.Segments.Single().Geometry!); + edge.RouteGeometry.Should().NotBeEquivalentTo(corruptPoints); + } + + [Fact] + public void Build_InvalidSegmentGeometry_RetainsEdgeWithoutDetailedGeometry() + { + var (trip, fromId, toId) = CreateTrip("{not json"); + + var graph = TripNavigationGraphBuilder.Build(trip); + + var edge = graph.GetEdgeBetween(fromId.ToString(), toId.ToString()); + edge.Should().NotBeNull(); + edge!.RouteGeometry.Should().BeNullOrEmpty(); + } + + private static (TripDetails Trip, Guid FromId, Guid ToId) CreateTrip(string geometry) + { + var fromId = Guid.NewGuid(); + var toId = Guid.NewGuid(); + return ( + new TripDetails + { + Id = Guid.NewGuid(), + Regions = + [ + new TripRegion + { + Places = + [ + new TripPlace { Id = fromId, Name = "From", Latitude = 37.9838, Longitude = 23.7275 }, + new TripPlace { Id = toId, Name = "To", Latitude = 37.9844, Longitude = 23.7281 } + ] + } + ], + Segments = + [ + new TripSegment + { + Id = Guid.NewGuid(), + OriginId = fromId, + DestinationId = toId, + Geometry = geometry + } + ] + }, + fromId, + toId); + } +} diff --git a/tests/WayfarerMobile.Tests/Unit/Services/TripLayerServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/TripLayerServiceTests.cs index 56619684..37c1b7aa 100644 --- a/tests/WayfarerMobile.Tests/Unit/Services/TripLayerServiceTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Services/TripLayerServiceTests.cs @@ -1,25 +1,27 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using WayfarerMobile.Core.Helpers; namespace WayfarerMobile.Tests.Unit.Services; /// /// Unit tests for TripLayerService. -/// Tests place marker creation, segment polyline styling, and layer management. +/// Tests place marker creation, segment styling, and layer management. /// /// /// TripLayerService manages trip-related map layers including: /// - Place markers with custom icons or colored dot fallbacks /// - Segment polylines with transport mode styling /// -/// This test file includes test-local implementations since the main +/// This test file includes test-local implementations for non-geometry behavior since the main /// WayfarerMobile project targets MAUI platforms (android/ios) which cannot be directly /// referenced from a pure .NET test project. The tests verify the core logic: /// - Place marker creation with valid coordinates /// - Empty places list handling /// - Zero coordinates skipped /// - Transport mode styling (walk, drive, bike, transit colors) -/// - Segment polyline creation +/// Segment geometry parsing is intentionally not mirrored here; a source contract below verifies +/// that the production service calls the shared Core parser before Mapsui projection. /// - Layer clearing /// public class TripLayerServiceTests @@ -233,6 +235,20 @@ public void UpdateTripSegments_ValidSegment_CreatesPolyline() _segmentsLayer.Features.Should().HaveCount(1); } + [Fact] + public void ProductionUpdateTripSegments_UsesSharedParserAndSkipsFailures() + { + var sourcePath = Path.Combine( + AppContext.BaseDirectory, + "..", "..", "..", "..", "..", + "src", "WayfarerMobile", "Services", "TripLayerService.cs"); + var source = File.ReadAllText(Path.GetFullPath(sourcePath)); + + source.Should().Contain("TripSegmentGeometryParser.Parse(segment.Geometry)"); + source.Should().Contain("if (!parseResult.IsSuccess)"); + source.Should().NotContain("ParseGeoJsonLineString"); + } + [Fact] public void UpdateTripSegments_NullGeometry_SkipsSegment() {