diff --git a/docs/12-Services.md b/docs/12-Services.md index 9e847b7..b889ee2 100644 --- a/docs/12-Services.md +++ b/docs/12-Services.md @@ -723,7 +723,7 @@ await _databaseService.MarkLocationServerRejectedAsync(id, "threshold"); int deleted = await _databaseService.PurgeSyncedLocationsAsync(daysOld: 7); ``` -### Trip Cache Operations +### Downloaded Trip Data Operations ```csharp // Get downloaded trips diff --git a/src/WayfarerMobile.Core/Interfaces/IApiClient.cs b/src/WayfarerMobile.Core/Interfaces/IApiClient.cs index 998994c..d4c15cd 100644 --- a/src/WayfarerMobile.Core/Interfaces/IApiClient.cs +++ b/src/WayfarerMobile.Core/Interfaces/IApiClient.cs @@ -195,16 +195,6 @@ Task DeleteRegionAsync( TripUpdateRequest request, CancellationToken cancellationToken = default); - /// - /// Gets trip geographic boundary for tile download calculation. - /// - /// The trip ID. - /// Cancellation token. - /// Trip boundary response with bounding box, or null if not found. - Task GetTripBoundaryAsync( - Guid tripId, - CancellationToken cancellationToken = default); - #endregion #region Segment Operations diff --git a/src/WayfarerMobile.Core/Models/TripModels.cs b/src/WayfarerMobile.Core/Models/TripModels.cs index 198b6a1..413de33 100644 --- a/src/WayfarerMobile.Core/Models/TripModels.cs +++ b/src/WayfarerMobile.Core/Models/TripModels.cs @@ -1031,94 +1031,6 @@ public class DownloadedTrip public bool IsFullyDownloaded => Status == "complete"; } -/// -/// Trip boundary response from server for tile download calculation. -/// -public class TripBoundaryResponse -{ - /// - /// Gets or sets the trip ID. - /// - [JsonPropertyName("tripId")] - public Guid TripId { get; set; } - - /// - /// Gets or sets the trip name. - /// - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - /// - /// Gets or sets the bounding box. - /// - [JsonPropertyName("boundingBox")] - public BoundingBox BoundingBox { get; set; } = new(); -} - -/// -/// Tile coordinate for download. -/// Implements IEquatable for proper collection operations (e.g., List.Remove). -/// This class is immutable after construction (init-only properties). -/// -public class TileCoordinate : IEquatable -{ - /// - /// Gets the zoom level. - /// - public int Zoom { get; init; } - - /// - /// Gets the X coordinate. - /// - public int X { get; init; } - - /// - /// Gets the Y coordinate. - /// - public int Y { get; init; } - - /// - /// Gets the tile URL from a server template. - /// - /// URL template with {z}, {x}, {y} placeholders. - /// Full tile URL. - public string GetTileUrl(string urlTemplate) => - urlTemplate.Replace("{z}", Zoom.ToString()) - .Replace("{x}", X.ToString()) - .Replace("{y}", Y.ToString()); - - /// - /// Gets a unique identifier for this tile. - /// - public string Id => $"{Zoom}-{X}-{Y}"; - - /// - public bool Equals(TileCoordinate? other) - { - if (other is null) return false; - if (ReferenceEquals(this, other)) return true; - return Zoom == other.Zoom && X == other.X && Y == other.Y; - } - - /// - public override bool Equals(object? obj) => Equals(obj as TileCoordinate); - - /// - public override int GetHashCode() => HashCode.Combine(Zoom, X, Y); - - /// - /// Equality operator. - /// - public static bool operator ==(TileCoordinate? left, TileCoordinate? right) => - left?.Equals(right) ?? right is null; - - /// - /// Inequality operator. - /// - public static bool operator !=(TileCoordinate? left, TileCoordinate? right) => - !(left == right); -} - /// /// Location from the timeline API with full details. /// diff --git a/src/WayfarerMobile/Interfaces/ITripContentService.cs b/src/WayfarerMobile/Interfaces/ITripContentService.cs index 7fe6cd4..27bd5db 100644 --- a/src/WayfarerMobile/Interfaces/ITripContentService.cs +++ b/src/WayfarerMobile/Interfaces/ITripContentService.cs @@ -24,15 +24,14 @@ public interface ITripContentService Task> GetTripsNeedingUpdateAsync(); /// - /// Syncs trip metadata with the server (updates places, segments, areas). - /// Does not handle tile downloads - returns whether bounding box changed. + /// Syncs downloaded Trip metadata with the server, including its bounding box. /// /// The server-side trip ID. /// If true, sync regardless of version. /// Optional progress reporter. /// Cancellation token. - /// Tuple of (updated trip entity or null, whether bounding box changed significantly). - Task<(DownloadedTripEntity? Trip, bool BoundingBoxChanged)> SyncTripMetadataAsync( + /// The updated Trip entity, or null when synchronization cannot complete. + Task SyncTripMetadataAsync( Guid tripServerId, bool forceSync = false, IProgress? progress = null, @@ -67,11 +66,4 @@ public interface ITripContentService /// List of trip segments. Task> GetOfflineSegmentsAsync(Guid tripServerId); - /// - /// Checks if bounding box has changed significantly (more than ~1km at equator). - /// - /// The local trip entity. - /// The server bounding box. - /// True if bounding box changed significantly. - bool HasBoundingBoxChangedSignificantly(DownloadedTripEntity trip, BoundingBox serverBoundingBox); } diff --git a/src/WayfarerMobile/MauiProgram.cs b/src/WayfarerMobile/MauiProgram.cs index efb5b20..1abf400 100644 --- a/src/WayfarerMobile/MauiProgram.cs +++ b/src/WayfarerMobile/MauiProgram.cs @@ -423,7 +423,7 @@ private static void ConfigureHttpClients(IServiceCollection services) client.DefaultRequestHeaders.Add("User-Agent", "WayfarerMobile/1.0 (Location tracking app)"); }); - // Tiles - map tile downloads with 60s timeout + // Tiles - request-driven interactive OpenStreetMap transport with 60s timeout services.AddHttpClient("Tiles", client => { client.Timeout = TimeSpan.FromSeconds(60); diff --git a/src/WayfarerMobile/Services/ApiClient.cs b/src/WayfarerMobile/Services/ApiClient.cs index f773649..2d9fa64 100644 --- a/src/WayfarerMobile/Services/ApiClient.cs +++ b/src/WayfarerMobile/Services/ApiClient.cs @@ -263,51 +263,6 @@ public async Task> GetTripsAsync(CancellationToken cancellatio } } - /// - /// Gets trip boundary for tile download calculation. - /// - /// The trip ID. - /// Cancellation token. - /// Trip boundary response or null if not found. - public async Task GetTripBoundaryAsync(Guid tripId, CancellationToken cancellationToken = default) - { - if (!IsConfigured) - { - _logger.LogWarning("Cannot get trip boundary - API is not configured"); - return null; - } - - try - { - var response = await ExecuteWithRetryAsync( - () => CreateRequest(HttpMethod.Get, $"/api/trips/{tripId}/boundary"), - cancellationToken); - - if (response.IsSuccessStatusCode) - { - return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); - } - - _logger.LogWarning("Failed to get trip boundary: {StatusCode}", response.StatusCode); - return null; - } - catch (HttpRequestException ex) - { - _logger.LogNetworkWarningIfOnline("Network error getting trip boundary for {TripId}: {Message}", tripId, ex.Message); - return null; - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - _logger.LogError(ex, "Request timed out getting trip boundary for {TripId}", tripId); - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error getting trip boundary for {TripId}", tripId); - return null; - } - } - #region Place CRUD /// @@ -1069,11 +1024,6 @@ public async Task DeleteRegionAsync(Guid regionId, CancellationToken cance #endregion - /// - /// Gets the underlying HttpClient for direct tile downloads. - /// - public HttpClient HttpClient => HttpClientInstance; - /// public async Task GetTimelineLocationsAsync( string dateType, diff --git a/src/WayfarerMobile/Services/TripContentService.cs b/src/WayfarerMobile/Services/TripContentService.cs index ed6ecce..2d7a38e 100644 --- a/src/WayfarerMobile/Services/TripContentService.cs +++ b/src/WayfarerMobile/Services/TripContentService.cs @@ -112,7 +112,7 @@ public async Task> GetTripsNeedingUpdateAsync() } /// - public async Task<(DownloadedTripEntity? Trip, bool BoundingBoxChanged)> SyncTripMetadataAsync( + public async Task SyncTripMetadataAsync( Guid tripServerId, bool forceSync = false, IProgress? progress = null, @@ -124,13 +124,13 @@ public async Task> GetTripsNeedingUpdateAsync() if (localTrip == null) { _logger.LogWarning("Cannot sync trip {TripId} - not downloaded", tripServerId); - return (null, false); + return null; } if (!IsNetworkAvailable()) { _logger.LogWarning("Cannot sync trip - no network connection"); - return (null, false); + return null; } _logger.LogInformation("Starting metadata sync for trip: {TripName}", localTrip.Name); @@ -141,14 +141,14 @@ public async Task> GetTripsNeedingUpdateAsync() if (serverTrip == null) { _logger.LogWarning("Failed to fetch trip details for sync: {TripId}", tripServerId); - return (null, false); + return null; } // Check if update is needed (unless force sync) if (!forceSync && serverTrip.Version <= localTrip.Version) { _logger.LogInformation("Trip {TripName} is already up to date (v{Version})", localTrip.Name, localTrip.Version); - return (localTrip, false); + return localTrip; } RaiseProgress(progress, localTrip.Id, 15, "Updating regions..."); @@ -179,21 +179,12 @@ public async Task> GetTripsNeedingUpdateAsync() await _areaRepository.SaveOfflinePolygonsAsync(localTrip.Id, polygons); localTrip.AreaCount = polygons.Count; - RaiseProgress(progress, localTrip.Id, 75, "Checking map coverage..."); - - // Check if bounding box changed significantly (caller will handle tile re-download) - var boundingBoxChanged = serverTrip.BoundingBox != null && - HasBoundingBoxChangedSignificantly(localTrip, serverTrip.BoundingBox); - - if (boundingBoxChanged && serverTrip.BoundingBox != null) + if (serverTrip.BoundingBox != null) { - // Update bounding box metadata from server localTrip.BoundingBoxNorth = serverTrip.BoundingBox.North; localTrip.BoundingBoxSouth = serverTrip.BoundingBox.South; localTrip.BoundingBoxEast = serverTrip.BoundingBox.East; localTrip.BoundingBoxWest = serverTrip.BoundingBox.West; - - _logger.LogInformation("Bounding box changed for trip {TripName}", localTrip.Name); } // Update version and timestamps @@ -208,7 +199,7 @@ public async Task> GetTripsNeedingUpdateAsync() _logger.LogInformation("Trip metadata synced: {TripName} (v{Version}, {PlaceCount} places, {SegmentCount} segments)", localTrip.Name, localTrip.Version, places.Count, segments.Count); - return (localTrip, boundingBoxChanged); + return localTrip; } catch (OperationCanceledException) { @@ -218,7 +209,7 @@ public async Task> GetTripsNeedingUpdateAsync() catch (Exception ex) { _logger.LogError(ex, "Failed to sync trip metadata: {TripId}", tripServerId); - return (null, false); + return null; } } @@ -250,7 +241,7 @@ public async Task SyncAllTripsMetadataAsync(CancellationToken cancellationT try { - var (result, _) = await SyncTripMetadataAsync(trip.ServerId, forceSync: false, cancellationToken: cancellationToken); + var result = await SyncTripMetadataAsync(trip.ServerId, forceSync: false, cancellationToken: cancellationToken); if (result != null) { syncedCount++; @@ -487,17 +478,6 @@ public async Task> GetOfflineSegmentsAsync(Guid tripServerId) }).ToList(); } - /// - public bool HasBoundingBoxChangedSignificantly(DownloadedTripEntity trip, BoundingBox serverBoundingBox) - { - const double threshold = 0.01; // ~1km at equator - - return Math.Abs(trip.BoundingBoxNorth - serverBoundingBox.North) > threshold || - Math.Abs(trip.BoundingBoxSouth - serverBoundingBox.South) > threshold || - Math.Abs(trip.BoundingBoxEast - serverBoundingBox.East) > threshold || - Math.Abs(trip.BoundingBoxWest - serverBoundingBox.West) > threshold; - } - /// /// Checks if network is available. /// diff --git a/src/WayfarerMobile/Services/TripSyncCoordinator.cs b/src/WayfarerMobile/Services/TripSyncCoordinator.cs index cb616a4..70b7cd7 100644 --- a/src/WayfarerMobile/Services/TripSyncCoordinator.cs +++ b/src/WayfarerMobile/Services/TripSyncCoordinator.cs @@ -27,8 +27,7 @@ public TripSyncCoordinator(ITripContentService content, ITripRepository trips, I public async Task SyncTripAsync(Guid tripServerId, bool forceSync = false, CancellationToken cancellationToken = default) { var progress = new Progress(e => ProgressChanged?.Invoke(this, e)); - var (trip, _) = await _content.SyncTripMetadataAsync(tripServerId, forceSync, progress, cancellationToken); - return trip; + return await _content.SyncTripMetadataAsync(tripServerId, forceSync, progress, cancellationToken); } public Task> GetTripsNeedingUpdateAsync() => _content.GetTripsNeedingUpdateAsync(); diff --git a/src/WayfarerMobile/ViewModels/TripDownloadViewModel.cs b/src/WayfarerMobile/ViewModels/TripDownloadViewModel.cs index 90ac94c..3b36fb2 100644 --- a/src/WayfarerMobile/ViewModels/TripDownloadViewModel.cs +++ b/src/WayfarerMobile/ViewModels/TripDownloadViewModel.cs @@ -98,8 +98,6 @@ private void OnProgressChanged(object? sender, Core.Interfaces.DownloadProgressE if (_serverId is { } id) _callbacks?.UpdateItemProgress(id, DownloadProgress, e.ProgressPercent < 100); } - public Task RefreshPausedDownloadsCountAsync() => Task.CompletedTask; - public void Dispose() { _downloads.ProgressChanged -= OnProgressChanged; diff --git a/tests/WayfarerMobile.Tests/Unit/Models/TripModelsTests.cs b/tests/WayfarerMobile.Tests/Unit/Models/TripModelsTests.cs index ad322b7..062b4c5 100644 --- a/tests/WayfarerMobile.Tests/Unit/Models/TripModelsTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/Models/TripModelsTests.cs @@ -430,84 +430,6 @@ public void TimelineLocation_DisplayLocation_PlaceIsEmptyCountryHasValue_Returns #endregion - #region TileCoordinate Tests - - [Fact] - public void TileCoordinate_GetTileUrl_SubstitutesPlaceholders() - { - // Arrange - var tile = new TileCoordinate - { - Zoom = 15, - X = 17389, - Y = 11236 - }; - var urlTemplate = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; - - // Act - var result = tile.GetTileUrl(urlTemplate); - - // Assert - result.Should().Be("https://tile.openstreetmap.org/15/17389/11236.png"); - } - - [Fact] - public void TileCoordinate_GetTileUrl_HandlesMultiplePlaceholderFormats() - { - // Arrange - var tile = new TileCoordinate - { - Zoom = 10, - X = 100, - Y = 200 - }; - var urlTemplate = "https://tiles.example.com/tiles/{z}/{x}/{y}?format=png"; - - // Act - var result = tile.GetTileUrl(urlTemplate); - - // Assert - result.Should().Be("https://tiles.example.com/tiles/10/100/200?format=png"); - } - - [Fact] - public void TileCoordinate_Id_ReturnsCorrectFormat() - { - // Arrange - var tile = new TileCoordinate - { - Zoom = 15, - X = 17389, - Y = 11236 - }; - - // Act - var result = tile.Id; - - // Assert - result.Should().Be("15-17389-11236"); - } - - [Fact] - public void TileCoordinate_Id_ZeroValues_ReturnsCorrectFormat() - { - // Arrange - var tile = new TileCoordinate - { - Zoom = 0, - X = 0, - Y = 0 - }; - - // Act - var result = tile.Id; - - // Assert - result.Should().Be("0-0-0"); - } - - #endregion - #region PublicTripSummary Tests [Fact] diff --git a/tests/WayfarerMobile.Tests/Unit/Services/TripContentServiceTests.cs b/tests/WayfarerMobile.Tests/Unit/Services/TripContentServiceTests.cs new file mode 100644 index 0000000..46cdb44 --- /dev/null +++ b/tests/WayfarerMobile.Tests/Unit/Services/TripContentServiceTests.cs @@ -0,0 +1,103 @@ +using Microsoft.Extensions.Logging.Abstractions; +using WayfarerMobile.Data.Entities; +using WayfarerMobile.Data.Repositories; +using WayfarerMobile.Interfaces; +using WayfarerMobile.Services; + +namespace WayfarerMobile.Tests.Unit.Services; + +public sealed class TripContentServiceTests +{ + [Fact] + public async Task SyncTripMetadataAsync_ReportsOnlyTripDataProgressAndCompletion() + { + var serverId = Guid.NewGuid(); + var localTrip = new DownloadedTripEntity + { + Id = 42, + ServerId = serverId, + Name = "Old trip name", + Version = 1 + }; + var serverTrip = new TripDetails + { + Id = serverId, + Name = "Updated trip name", + Version = 2, + BoundingBox = new BoundingBox { North = 54, South = 50, East = 8, West = 3 } + }; + var areas = new List { new() }; + var places = new List { new() }; + var segments = new List { new() }; + var polygons = new List { new() }; + + var api = new Mock(); + api.Setup(x => x.GetTripDetailsAsync(serverId, It.IsAny())).ReturnsAsync(serverTrip); + var trips = new Mock(); + trips.Setup(x => x.GetDownloadedTripByServerIdAsync(serverId)).ReturnsAsync(localTrip); + var placeRepository = new Mock(); + var segmentRepository = new Mock(); + var areaRepository = new Mock(); + var metadata = new Mock(); + metadata.Setup(x => x.BuildAreas(serverTrip)).Returns(areas); + metadata.Setup(x => x.BuildPlaces(serverTrip)).Returns(places); + metadata.Setup(x => x.BuildSegments(serverTrip)).Returns(segments); + metadata.Setup(x => x.BuildPolygons(serverTrip)).Returns(polygons); + var connectivity = new Mock(); + connectivity.SetupGet(x => x.NetworkAccess).Returns(NetworkAccess.Internet); + var progress = new SynchronousProgressRecorder(); + var service = new TripContentService( + api.Object, + trips.Object, + placeRepository.Object, + segmentRepository.Object, + areaRepository.Object, + metadata.Object, + connectivity.Object, + NullLogger.Instance); + + var result = await service.SyncTripMetadataAsync(serverId, forceSync: true, progress); + + result.Should().BeSameAs(localTrip); + progress.Values.Should().HaveCount(6); + progress.Values.Select(value => value.ProgressPercent).Should().BeInAscendingOrder(); + progress.Values[^1].ProgressPercent.Should().Be(100); + progress.Values.Select(value => value.TripId).Should().OnlyContain(id => id == localTrip.Id); + progress.Values.Select(value => value.StatusMessage).Should().SatisfyRespectively( + status => status.Should().ContainEquivalentOf("update"), + status => status.Should().ContainEquivalentOf("region"), + status => status.Should().ContainEquivalentOf("place"), + status => status.Should().ContainEquivalentOf("segment"), + status => status.Should().MatchEquivalentOf("*polygon*"), + status => status.Should().ContainEquivalentOf("complete")); + progress.Values.Select(value => value.StatusMessage).Should().OnlyContain(status => + !new[] { "raster", "tile", "coverage", "prefetch", "pause", "resume", "offline map" } + .Any(term => status.Contains(term, StringComparison.OrdinalIgnoreCase))); + + localTrip.Should().BeEquivalentTo(new + { + Name = serverTrip.Name, + Version = serverTrip.Version, + RegionCount = 1, + PlaceCount = 1, + SegmentCount = 1, + AreaCount = 1, + BoundingBoxNorth = 54d, + BoundingBoxSouth = 50d, + BoundingBoxEast = 8d, + BoundingBoxWest = 3d + }); + areaRepository.Verify(x => x.SaveOfflineAreasAsync(localTrip.Id, areas), Times.Once); + placeRepository.Verify(x => x.SaveOfflinePlacesAsync(localTrip.Id, places), Times.Once); + segmentRepository.Verify(x => x.SaveOfflineSegmentsAsync(localTrip.Id, segments), Times.Once); + areaRepository.Verify(x => x.SaveOfflinePolygonsAsync(localTrip.Id, polygons), Times.Once); + trips.Verify(x => x.SaveDownloadedTripAsync(localTrip), Times.Once); + } + + private sealed class SynchronousProgressRecorder : IProgress + { + public List Values { get; } = []; + + public void Report(T value) => Values.Add(value); + } +} diff --git a/tests/WayfarerMobile.Tests/Unit/ViewModels/MyTripsViewModelTests.cs b/tests/WayfarerMobile.Tests/Unit/ViewModels/MyTripsViewModelTests.cs index b6e1cba..ac1b4ef 100644 --- a/tests/WayfarerMobile.Tests/Unit/ViewModels/MyTripsViewModelTests.cs +++ b/tests/WayfarerMobile.Tests/Unit/ViewModels/MyTripsViewModelTests.cs @@ -463,13 +463,6 @@ public void OnAppearingAsync_LoadsIfTripsEmpty() // if (Trips.Count == 0) await LoadTripsAsync(); } - [Fact] - public void OnAppearingAsync_ChecksForPausedDownloads() - { - // Document expected behavior: - // await CheckForPausedDownloadsAsync(); - } - [Fact] public void OnAppearingAsync_RefreshesLoadedState() { diff --git a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj index 5d38f28..2629738 100644 --- a/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj +++ b/tests/WayfarerMobile.Tests/WayfarerMobile.Tests.csproj @@ -10,10 +10,22 @@ + + + + + + + + + + + +