diff --git a/Areas/User/Controllers/TripImportController.cs b/Areas/User/Controllers/TripImportController.cs index d78027fb..fbce21a7 100644 --- a/Areas/User/Controllers/TripImportController.cs +++ b/Areas/User/Controllers/TripImportController.cs @@ -10,6 +10,8 @@ namespace Wayfarer.Areas.User.Controllers; [Area("User"), Authorize, Route("User/Trip/[action]")] public class TripImportController : BaseController { + private const string GenericRouteReminder = + "Imported KML routes do not contain reliable transport information. Select a transport mode for each route where needed to enable automatic duration estimates."; private readonly ITripImportService _svc; public TripImportController( @@ -37,6 +39,11 @@ public async Task Import( { var result = await _svc.ImportWayfarerKmlAsync(stream, userId, mode, HttpContext.RequestAborted); var redirectUrl = $"/User/Trip/Edit/{result.TripId:D}"; + if (result.IsGenericWithRoutes) + { + TempData["AlertType"] = "info"; + TempData["AlertMessage"] = GenericRouteReminder; + } return Json(new { status = "success", diff --git a/Parsers/GoogleMyMapsKmlParser.cs b/Parsers/GoogleMyMapsKmlParser.cs index dd5eabef..2820c537 100644 --- a/Parsers/GoogleMyMapsKmlParser.cs +++ b/Parsers/GoogleMyMapsKmlParser.cs @@ -158,7 +158,8 @@ private static Trip BuildTrip( Id = Guid.NewGuid(), TripId = tripId, UserId = userId, - Mode = route.Owner.Element(Kml + "name")?.Value ?? "drive", + Mode = string.Empty, + TransportProfileId = null, RouteGeometry = new LineString(route.Budget.Coordinates.ToArray()) { SRID = 4326 }, EstimatedDistanceKm = null, EstimatedDuration = null, diff --git a/Services/ITripImportService.cs b/Services/ITripImportService.cs index 11d3758f..b5c88744 100644 --- a/Services/ITripImportService.cs +++ b/Services/ITripImportService.cs @@ -21,7 +21,10 @@ public sealed record TripImportNotice( int? AdditionalRouteCount = null); /// Bounded successful import result returned without source geometry. -public sealed record TripImportResult(Guid TripId, IReadOnlyList Notices) +public sealed record TripImportResult( + Guid TripId, + IReadOnlyList Notices, + bool IsGenericWithRoutes = false) { /// Supports existing internal consumers that require only the imported identity. public static implicit operator Guid(TripImportResult result) => result.TripId; diff --git a/Services/TripImportService.Generic.cs b/Services/TripImportService.Generic.cs index d453ed00..53c9250b 100644 --- a/Services/TripImportService.Generic.cs +++ b/Services/TripImportService.Generic.cs @@ -30,7 +30,6 @@ private async Task ImportGenericAsync( var reconciledTags = await _tagReconciler.ReconcileAsync(importedTagTokens, cancellationToken); foreach (var tag in reconciledTags) target.Tags.Add(tag); AddShadowRegion(target, userId); - await ResolveImportedProfilesAsync(target.Segments, cancellationToken); _dbContext.Trips.Add(target); await _dbContext.SaveChangesAsync(cancellationToken); _dbContext.ChangeTracker.Clear(); @@ -38,7 +37,7 @@ await SegmentMeasurementWriterReconciler.ReconcileTripAsync( _dbContext, target.Id, allowUnavailableAutomatic: true, cancellationToken); if (transaction is not null) await transaction.CommitAsync(cancellationToken); _dbContext.ChangeTracker.Clear(); - return new(target.Id, parsed.Notices); + return new(target.Id, parsed.Notices, target.Segments.Count > 0); } catch { diff --git a/Services/TripImportService.cs b/Services/TripImportService.cs index b99b2181..cf0ccd21 100644 --- a/Services/TripImportService.cs +++ b/Services/TripImportService.cs @@ -166,18 +166,6 @@ private async Task ValidateCompatibilityMeasurementsAsync( } } - /// Links known import modes before reconciliation while leaving unknown modes to the database compatibility trigger. - private async Task ResolveImportedProfilesAsync(IEnumerable segments, CancellationToken cancellationToken) - { - var profiles = await _dbContext.Set().AsNoTracking().ToDictionaryAsync( - profile => profile.Key, cancellationToken); - foreach (var segment in segments) - { - var normalized = TransportProfile.NormalizeKey(segment.Mode); - segment.TransportProfileId = profiles.TryGetValue(normalized, out var profile) ? profile.Id : null; - } - } - /* ---------- helpers ------------------------------------------------- */ static Trip CreateNewShell(Trip parsed, string userId) { diff --git a/tests/Wayfarer.Tests/Controllers/TripImportControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TripImportControllerTests.cs index ddd093b7..469c1209 100644 --- a/tests/Wayfarer.Tests/Controllers/TripImportControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TripImportControllerTests.cs @@ -3,6 +3,7 @@ using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Wayfarer.Areas.User.Controllers; @@ -18,6 +19,9 @@ namespace Wayfarer.Tests.Controllers; /// public class TripImportControllerTests : TestBase { + private const string GenericRouteReminder = + "Imported KML routes do not contain reliable transport information. Select a transport mode for each route where needed to enable automatic duration estimates."; + [Fact] public async Task Import_ReturnsBadRequest_WhenFileMissing() { @@ -56,6 +60,32 @@ public async Task Import_ReturnsBoundedCanonicalSuccessJson(TripImportMode mode) Assert.Single(Property>(json.Value, "notices")); } + /// Successful generic route imports install one informational editor reminder. + [Fact] + public async Task Import_GenericRoute_InstallsOneTimeInformationAndReturnsEditorRedirect() + { + var db = CreateDbContext(); + var service = new TripImportService(db, NullLogger.Instance); + var controller = BuildController(service); + ConfigureControllerWithUser(controller, "u1"); + controller.TempData = new TempDataDictionary(controller.HttpContext, Mock.Of()); + var file = CreateFormFile(""" + Generic + Ella to Kandy by TRAIN80,7 81,7 + + """); + + var result = await controller.Import(file, TripImportMode.CreateNew); + + var json = Assert.IsType(result); + Assert.Equal("success", Property(json.Value, "status")); + var tripId = Property(json.Value, "tripId"); + Assert.Equal($"/User/Trip/Edit/{tripId:D}", Property(json.Value, "redirectUrl")); + Assert.Equal("info", controller.TempData["AlertType"]); + Assert.Equal(GenericRouteReminder, controller.TempData["AlertMessage"]); + Assert.Equal(2, controller.TempData.Count); + } + [Fact] public async Task Import_ReturnsDuplicateJson_WhenDuplicateDetected() { diff --git a/tests/Wayfarer.Tests/Parsers/GoogleMyMapsKmlParserTests.cs b/tests/Wayfarer.Tests/Parsers/GoogleMyMapsKmlParserTests.cs index c0f12ce0..e2530580 100644 --- a/tests/Wayfarer.Tests/Parsers/GoogleMyMapsKmlParserTests.cs +++ b/tests/Wayfarer.Tests/Parsers/GoogleMyMapsKmlParserTests.cs @@ -154,7 +154,7 @@ public void Parse_PlacemarkWithLineString_CreatesSegment() // Assert Assert.Single(trip.Segments); var segment = trip.Segments.First(); - Assert.Equal("drive", segment.Mode); + Assert.Equal(string.Empty, segment.Mode); Assert.Equal(trip.Id, segment.TripId); Assert.Equal("user1", segment.UserId); Assert.IsType(segment.RouteGeometry); @@ -221,7 +221,7 @@ public void Parse_SegmentOutsideFolder_AddsToTripSegments() // Assert Assert.Single(trip.Segments); - Assert.Equal("route", trip.Segments.First().Mode); + Assert.Equal(string.Empty, trip.Segments.First().Mode); } [Fact] diff --git a/tests/Wayfarer.Tests/Services/TripImportPostgresTests.cs b/tests/Wayfarer.Tests/Services/TripImportPostgresTests.cs index 0f86d1d8..a348be20 100644 --- a/tests/Wayfarer.Tests/Services/TripImportPostgresTests.cs +++ b/tests/Wayfarer.Tests/Services/TripImportPostgresTests.cs @@ -51,23 +51,25 @@ public async Task WayfarerV1RouteImport_ResolvesProfileAndReconcilesMeasurements Assert.NotNull(segment.EstimatedDuration); } - /// Generic route KML defaults Automatic and derives distance and duration through the known catalog profile. + /// Generic route titles remain descriptive text and never select a transport profile. [PostgresFact] - public async Task GenericKmlRouteImport_DefaultsAutomaticAndCalculatesKnownMode() + public async Task GenericKmlRouteImport_LeavesTransportUnassigned() { fixture.RequireAvailable(); var user = await fixture.CreateUserAsync(); await using var context = fixture.CreateContext(); var service = new TripImportService(context, NullLogger.Instance, CreateReconciler(context)); - var tripId = await service.ImportWayfarerKmlAsync(ToStream(CreateGenericRouteKml("walk")), user.Id, TripImportMode.CreateNew); + var tripId = await service.ImportWayfarerKmlAsync( + ToStream(CreateGenericRouteKml("Ella to Kandy by TRAIN")), user.Id, TripImportMode.CreateNew); fixture.RegisterTrip(tripId); var segment = await context.Segments.AsNoTracking().SingleAsync(item => item.TripId == tripId); + Assert.Equal(string.Empty, segment.Mode); + Assert.Null(segment.TransportProfileId); Assert.Equal(EstimatedDurationSource.Automatic, segment.EstimatedDurationSource); - Assert.NotNull(segment.TransportProfileId); Assert.NotNull(segment.EstimatedDistanceKm); - Assert.NotNull(segment.EstimatedDuration); + Assert.Null(segment.EstimatedDuration); } /// Proves generic rollback clears failed state and a retry persists only final budgeted geometry and measurements.