[AZ-365] Refactor C12: decompose RouteService.CreateRouteAsync

Extract RouteValidator (aggregating validator), RoutePointGraphBuilder
(point interpolation + sequence numbering), GeofenceGridCalculator
(NW/SE region centers), and RouteResponseMapper (entity -> DTO; also
used by GetRouteAsync, eliminating duplicate DTO assembly).

CreateRouteAsync shrinks 184 -> 52 LOC of orchestration. RouteService.cs
shrinks 295 -> 138 LOC overall. Validation aggregates all failures into
a single ArgumentException (AC-2); single-violation messages preserved
verbatim so existing RouteServiceTests pass unchanged. 28 new unit
tests for the four helpers (112/112 unit tests, smoke green).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 02:08:21 +03:00
parent d327000fb6
commit f7ad7aa5ab
11 changed files with 936 additions and 206 deletions
@@ -0,0 +1,46 @@
using SatelliteProvider.Common.DTO;
using SatelliteProvider.Common.Utils;
namespace SatelliteProvider.Services.RouteManagement;
public class GeofenceGridCalculator
{
public IReadOnlyList<GeoPoint> GenerateRegions(GeoPoint northWest, GeoPoint southEast, double regionSizeMeters)
{
ArgumentNullException.ThrowIfNull(northWest);
ArgumentNullException.ThrowIfNull(southEast);
if (regionSizeMeters <= 0)
{
throw new ArgumentOutOfRangeException(nameof(regionSizeMeters), "Region size must be positive");
}
var midLon = (northWest.Lon + southEast.Lon) / 2;
var midLat = (northWest.Lat + southEast.Lat) / 2;
var heightMeters = GeoUtils.CalculateDistance(
new GeoPoint(northWest.Lat, midLon),
new GeoPoint(southEast.Lat, midLon));
var widthMeters = GeoUtils.CalculateDistance(
new GeoPoint(midLat, northWest.Lon),
new GeoPoint(midLat, southEast.Lon));
var numLatSteps = Math.Max(1, (int)Math.Ceiling(heightMeters / regionSizeMeters));
var numLonSteps = Math.Max(1, (int)Math.Ceiling(widthMeters / regionSizeMeters));
var latStep = (northWest.Lat - southEast.Lat) / numLatSteps;
var lonStep = (southEast.Lon - northWest.Lon) / numLonSteps;
var regions = new List<GeoPoint>(numLatSteps * numLonSteps);
for (int latIdx = 0; latIdx < numLatSteps; latIdx++)
{
for (int lonIdx = 0; lonIdx < numLonSteps; lonIdx++)
{
var lat = northWest.Lat - (latIdx + 0.5) * latStep;
var lon = northWest.Lon + (lonIdx + 0.5) * lonStep;
regions.Add(new GeoPoint(lat, lon));
}
}
return regions;
}
}
@@ -0,0 +1,81 @@
using SatelliteProvider.Common.DTO;
using SatelliteProvider.Common.Utils;
namespace SatelliteProvider.Services.RouteManagement;
public class RoutePointGraphBuilder
{
public const double MaxPointSpacingMeters = 200.0;
public RoutePointGraph Build(IReadOnlyList<RoutePoint> userPoints)
{
ArgumentNullException.ThrowIfNull(userPoints);
if (userPoints.Count < 2)
{
throw new ArgumentException("Route must have at least 2 points", nameof(userPoints));
}
var allPoints = new List<RoutePointDto>();
var totalDistance = 0.0;
var sequenceNumber = 0;
for (int segmentIndex = 0; segmentIndex < userPoints.Count; segmentIndex++)
{
var current = userPoints[segmentIndex];
var isStart = segmentIndex == 0;
var isEnd = segmentIndex == userPoints.Count - 1;
var currentGeo = new GeoPoint(current.Latitude, current.Longitude);
double? distanceFromPrevious = null;
if (allPoints.Count > 0)
{
var prev = allPoints[^1];
var prevGeo = new GeoPoint(prev.Latitude, prev.Longitude);
distanceFromPrevious = GeoUtils.CalculateDistance(prevGeo, currentGeo);
totalDistance += distanceFromPrevious.Value;
}
allPoints.Add(new RoutePointDto
{
Latitude = current.Latitude,
Longitude = current.Longitude,
PointType = isStart ? "start" : (isEnd ? "end" : "action"),
SequenceNumber = sequenceNumber++,
SegmentIndex = segmentIndex,
DistanceFromPrevious = distanceFromPrevious,
});
if (isEnd)
{
continue;
}
var next = userPoints[segmentIndex + 1];
var nextGeo = new GeoPoint(next.Latitude, next.Longitude);
var intermediates = GeoUtils.CalculateIntermediatePoints(currentGeo, nextGeo, MaxPointSpacingMeters);
foreach (var intermediateGeo in intermediates)
{
var prev = allPoints[^1];
var prevGeo = new GeoPoint(prev.Latitude, prev.Longitude);
var distFromPrev = GeoUtils.CalculateDistance(prevGeo, intermediateGeo);
totalDistance += distFromPrev;
allPoints.Add(new RoutePointDto
{
Latitude = intermediateGeo.Lat,
Longitude = intermediateGeo.Lon,
PointType = "intermediate",
SequenceNumber = sequenceNumber++,
SegmentIndex = segmentIndex,
DistanceFromPrevious = distFromPrev,
});
}
}
return new RoutePointGraph(allPoints, totalDistance);
}
}
public record RoutePointGraph(IReadOnlyList<RoutePointDto> Points, double TotalDistanceMeters);
@@ -0,0 +1,50 @@
using SatelliteProvider.Common.DTO;
using SatelliteProvider.DataAccess.Models;
namespace SatelliteProvider.Services.RouteManagement;
public class RouteResponseMapper
{
public RouteResponse Map(RouteEntity route, IEnumerable<RoutePointDto> points)
{
ArgumentNullException.ThrowIfNull(route);
ArgumentNullException.ThrowIfNull(points);
var pointList = points as List<RoutePointDto> ?? points.ToList();
return new RouteResponse
{
Id = route.Id,
Name = route.Name,
Description = route.Description,
RegionSizeMeters = route.RegionSizeMeters,
ZoomLevel = route.ZoomLevel,
TotalDistanceMeters = route.TotalDistanceMeters,
TotalPoints = route.TotalPoints,
Points = pointList,
RequestMaps = route.RequestMaps,
MapsReady = route.MapsReady,
CsvFilePath = route.CsvFilePath,
SummaryFilePath = route.SummaryFilePath,
StitchedImagePath = route.StitchedImagePath,
TilesZipPath = route.TilesZipPath,
CreatedAt = route.CreatedAt,
UpdatedAt = route.UpdatedAt,
};
}
public RouteResponse Map(RouteEntity route, IEnumerable<RoutePointEntity> entities)
{
ArgumentNullException.ThrowIfNull(entities);
var pointDtos = entities.Select(p => new RoutePointDto
{
Latitude = p.Latitude,
Longitude = p.Longitude,
PointType = p.PointType,
SequenceNumber = p.SequenceNumber,
SegmentIndex = p.SegmentIndex,
DistanceFromPrevious = p.DistanceFromPrevious,
}).ToList();
return Map(route, pointDtos);
}
}
@@ -1,7 +1,6 @@
using Microsoft.Extensions.Logging;
using SatelliteProvider.Common.DTO;
using SatelliteProvider.Common.Interfaces;
using SatelliteProvider.Common.Utils;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.DataAccess.Repositories;
@@ -12,20 +11,45 @@ public class RouteService : IRouteService
private readonly IRouteRepository _routeRepository;
private readonly IRegionService _regionService;
private readonly ILogger<RouteService> _logger;
private const double MAX_POINT_SPACING_METERS = 200.0;
private readonly RouteValidator _validator;
private readonly RoutePointGraphBuilder _pointGraphBuilder;
private readonly GeofenceGridCalculator _geofenceGridCalculator;
private readonly RouteResponseMapper _responseMapper;
public RouteService(
IRouteRepository routeRepository,
IRegionService regionService,
ILogger<RouteService> logger)
: this(routeRepository, regionService, logger,
new RouteValidator(),
new RoutePointGraphBuilder(),
new GeofenceGridCalculator(),
new RouteResponseMapper())
{
}
public RouteService(
IRouteRepository routeRepository,
IRegionService regionService,
ILogger<RouteService> logger,
RouteValidator validator,
RoutePointGraphBuilder pointGraphBuilder,
GeofenceGridCalculator geofenceGridCalculator,
RouteResponseMapper responseMapper)
{
_routeRepository = routeRepository;
_regionService = regionService;
_logger = logger;
_validator = validator;
_pointGraphBuilder = pointGraphBuilder;
_geofenceGridCalculator = geofenceGridCalculator;
_responseMapper = responseMapper;
}
public async Task<RouteResponse> CreateRouteAsync(CreateRouteRequest request)
{
ArgumentNullException.ThrowIfNull(request);
// AZ-362: idempotent POST contract. A retried POST with the same caller-supplied
// Id returns the existing route instead of re-running point generation and
// re-queueing geofence regions.
@@ -38,85 +62,9 @@ public class RouteService : IRouteService
return existing;
}
if (request.Points.Count < 2)
{
throw new ArgumentException("Route must have at least 2 points");
}
_validator.Validate(request);
if (request.RegionSizeMeters < 100 || request.RegionSizeMeters > 10000)
{
throw new ArgumentException("Region size must be between 100 and 10000 meters");
}
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new ArgumentException("Route name is required");
}
var allPoints = new List<RoutePointDto>();
var totalDistance = 0.0;
var sequenceNumber = 0;
for (int segmentIndex = 0; segmentIndex < request.Points.Count; segmentIndex++)
{
var currentPoint = request.Points[segmentIndex];
var isStart = segmentIndex == 0;
var isEnd = segmentIndex == request.Points.Count - 1;
var geoPoint = new GeoPoint(currentPoint.Latitude, currentPoint.Longitude);
double? distanceFromPrevious = null;
if (allPoints.Count > 0)
{
var lastAddedPoint = allPoints[^1];
var prevGeoPoint = new GeoPoint(lastAddedPoint.Latitude, lastAddedPoint.Longitude);
distanceFromPrevious = GeoUtils.CalculateDistance(prevGeoPoint, geoPoint);
totalDistance += distanceFromPrevious.Value;
}
var pointType = isStart ? "start" : (isEnd ? "end" : "action");
var routePointDto = new RoutePointDto
{
Latitude = currentPoint.Latitude,
Longitude = currentPoint.Longitude,
PointType = pointType,
SequenceNumber = sequenceNumber++,
SegmentIndex = segmentIndex,
DistanceFromPrevious = distanceFromPrevious
};
allPoints.Add(routePointDto);
if (!isEnd)
{
var nextPoint = request.Points[segmentIndex + 1];
var startGeo = new GeoPoint(currentPoint.Latitude, currentPoint.Longitude);
var endGeo = new GeoPoint(nextPoint.Latitude, nextPoint.Longitude);
var intermediatePoints = GeoUtils.CalculateIntermediatePoints(startGeo, endGeo, MAX_POINT_SPACING_METERS);
foreach (var intermediateGeo in intermediatePoints)
{
var lastAddedPoint = allPoints[^1];
var prevGeo = new GeoPoint(lastAddedPoint.Latitude, lastAddedPoint.Longitude);
var distFromPrev = GeoUtils.CalculateDistance(prevGeo, intermediateGeo);
totalDistance += distFromPrev;
allPoints.Add(new RoutePointDto
{
Latitude = intermediateGeo.Lat,
Longitude = intermediateGeo.Lon,
PointType = "intermediate",
SequenceNumber = sequenceNumber++,
SegmentIndex = segmentIndex,
DistanceFromPrevious = distFromPrev
});
}
}
}
var graph = _pointGraphBuilder.Build(request.Points);
var now = DateTime.UtcNow;
var routeEntity = new RouteEntity
@@ -126,18 +74,18 @@ public class RouteService : IRouteService
Description = request.Description,
RegionSizeMeters = request.RegionSizeMeters,
ZoomLevel = request.ZoomLevel,
TotalDistanceMeters = totalDistance,
TotalPoints = allPoints.Count,
TotalDistanceMeters = graph.TotalDistanceMeters,
TotalPoints = graph.Points.Count,
RequestMaps = request.RequestMaps,
CreateTilesZip = request.CreateTilesZip,
MapsReady = false,
CreatedAt = now,
UpdatedAt = now
UpdatedAt = now,
};
await _routeRepository.InsertRouteAsync(routeEntity);
var pointEntities = allPoints.Select(p => new RoutePointEntity
var pointEntities = graph.Points.Select(p => new RoutePointEntity
{
Id = Guid.NewGuid(),
RouteId = request.Id,
@@ -147,79 +95,14 @@ public class RouteService : IRouteService
PointType = p.PointType,
SegmentIndex = p.SegmentIndex,
DistanceFromPrevious = p.DistanceFromPrevious,
CreatedAt = now
CreatedAt = now,
}).ToList();
await _routeRepository.InsertRoutePointsAsync(pointEntities);
if (request.Geofences?.Polygons != null && request.Geofences.Polygons.Count > 0)
{
for (int polygonIndex = 0; polygonIndex < request.Geofences.Polygons.Count; polygonIndex++)
{
var polygon = request.Geofences.Polygons[polygonIndex];
if (polygon.NorthWest is null || polygon.SouthEast is null)
{
throw new ArgumentException("Geofence polygon coordinates are required");
}
await ProcessGeofencePolygonsAsync(request);
if ((Math.Abs(polygon.NorthWest.Lat) < 0.0001 && Math.Abs(polygon.NorthWest.Lon) < 0.0001) ||
(Math.Abs(polygon.SouthEast.Lat) < 0.0001 && Math.Abs(polygon.SouthEast.Lon) < 0.0001))
{
throw new ArgumentException("Geofence polygon coordinates cannot be (0,0)");
}
if (polygon.NorthWest.Lat < -90 || polygon.NorthWest.Lat > 90 ||
polygon.SouthEast.Lat < -90 || polygon.SouthEast.Lat > 90 ||
polygon.NorthWest.Lon < -180 || polygon.NorthWest.Lon > 180 ||
polygon.SouthEast.Lon < -180 || polygon.SouthEast.Lon > 180)
{
throw new ArgumentException("Geofence polygon coordinates must be valid (lat: -90 to 90, lon: -180 to 180)");
}
if (polygon.NorthWest.Lat <= polygon.SouthEast.Lat)
{
throw new ArgumentException("Geofence northWest latitude must be greater than southEast latitude");
}
var geofenceRegions = CreateGeofenceRegionGrid(polygon.NorthWest, polygon.SouthEast, request.RegionSizeMeters);
foreach (var geofencePoint in geofenceRegions)
{
var geofenceRegionId = Guid.NewGuid();
await _regionService.RequestRegionAsync(
geofenceRegionId,
geofencePoint.Lat,
geofencePoint.Lon,
request.RegionSizeMeters,
request.ZoomLevel,
stitchTiles: false);
await _routeRepository.LinkRouteToRegionAsync(request.Id, geofenceRegionId, isGeofence: true, geofencePolygonIndex: polygonIndex);
}
}
}
return new RouteResponse
{
Id = routeEntity.Id,
Name = routeEntity.Name,
Description = routeEntity.Description,
RegionSizeMeters = routeEntity.RegionSizeMeters,
ZoomLevel = routeEntity.ZoomLevel,
TotalDistanceMeters = routeEntity.TotalDistanceMeters,
TotalPoints = routeEntity.TotalPoints,
Points = allPoints,
RequestMaps = routeEntity.RequestMaps,
MapsReady = routeEntity.MapsReady,
CsvFilePath = routeEntity.CsvFilePath,
SummaryFilePath = routeEntity.SummaryFilePath,
StitchedImagePath = routeEntity.StitchedImagePath,
TilesZipPath = routeEntity.TilesZipPath,
CreatedAt = routeEntity.CreatedAt,
UpdatedAt = routeEntity.UpdatedAt
};
return _responseMapper.Map(routeEntity, graph.Points);
}
public async Task<RouteResponse?> GetRouteAsync(Guid id)
@@ -231,65 +114,44 @@ public class RouteService : IRouteService
}
var points = await _routeRepository.GetRoutePointsAsync(id);
return new RouteResponse
{
Id = route.Id,
Name = route.Name,
Description = route.Description,
RegionSizeMeters = route.RegionSizeMeters,
ZoomLevel = route.ZoomLevel,
TotalDistanceMeters = route.TotalDistanceMeters,
TotalPoints = route.TotalPoints,
Points = points.Select(p => new RoutePointDto
{
Latitude = p.Latitude,
Longitude = p.Longitude,
PointType = p.PointType,
SequenceNumber = p.SequenceNumber,
SegmentIndex = p.SegmentIndex,
DistanceFromPrevious = p.DistanceFromPrevious
}).ToList(),
RequestMaps = route.RequestMaps,
MapsReady = route.MapsReady,
CsvFilePath = route.CsvFilePath,
SummaryFilePath = route.SummaryFilePath,
StitchedImagePath = route.StitchedImagePath,
TilesZipPath = route.TilesZipPath,
CreatedAt = route.CreatedAt,
UpdatedAt = route.UpdatedAt
};
return _responseMapper.Map(route, points);
}
private List<GeoPoint> CreateGeofenceRegionGrid(GeoPoint northWest, GeoPoint southEast, double regionSizeMeters)
private async Task ProcessGeofencePolygonsAsync(CreateRouteRequest request)
{
var regions = new List<GeoPoint>();
var northPoint = new GeoPoint(northWest.Lat, (northWest.Lon + southEast.Lon) / 2);
var southPoint = new GeoPoint(southEast.Lat, (northWest.Lon + southEast.Lon) / 2);
var heightMeters = GeoUtils.CalculateDistance(northPoint, southPoint);
var westPoint = new GeoPoint((northWest.Lat + southEast.Lat) / 2, northWest.Lon);
var eastPoint = new GeoPoint((northWest.Lat + southEast.Lat) / 2, southEast.Lon);
var widthMeters = GeoUtils.CalculateDistance(westPoint, eastPoint);
var numLatSteps = Math.Max(1, (int)Math.Ceiling(heightMeters / regionSizeMeters));
var numLonSteps = Math.Max(1, (int)Math.Ceiling(widthMeters / regionSizeMeters));
var latStep = (northWest.Lat - southEast.Lat) / numLatSteps;
var lonStep = (southEast.Lon - northWest.Lon) / numLonSteps;
for (int latIdx = 0; latIdx < numLatSteps; latIdx++)
var polygons = request.Geofences?.Polygons;
if (polygons is null || polygons.Count == 0)
{
for (int lonIdx = 0; lonIdx < numLonSteps; lonIdx++)
return;
}
for (int polygonIndex = 0; polygonIndex < polygons.Count; polygonIndex++)
{
var polygon = polygons[polygonIndex];
// Validator (above) guarantees NorthWest/SouthEast are non-null and well-formed.
var regions = _geofenceGridCalculator.GenerateRegions(
polygon.NorthWest!,
polygon.SouthEast!,
request.RegionSizeMeters);
foreach (var center in regions)
{
var lat = northWest.Lat - (latIdx + 0.5) * latStep;
var lon = northWest.Lon + (lonIdx + 0.5) * lonStep;
regions.Add(new GeoPoint(lat, lon));
var geofenceRegionId = Guid.NewGuid();
await _regionService.RequestRegionAsync(
geofenceRegionId,
center.Lat,
center.Lon,
request.RegionSizeMeters,
request.ZoomLevel,
stitchTiles: false);
await _routeRepository.LinkRouteToRegionAsync(
request.Id,
geofenceRegionId,
isGeofence: true,
geofencePolygonIndex: polygonIndex);
}
}
return regions;
}
}
@@ -0,0 +1,72 @@
using SatelliteProvider.Common.DTO;
namespace SatelliteProvider.Services.RouteManagement;
public class RouteValidator
{
public void Validate(CreateRouteRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<string>();
if (request.Points is null || request.Points.Count < 2)
{
errors.Add("Route must have at least 2 points");
}
if (request.RegionSizeMeters < 100 || request.RegionSizeMeters > 10000)
{
errors.Add("Region size must be between 100 and 10000 meters");
}
if (string.IsNullOrWhiteSpace(request.Name))
{
errors.Add("Route name is required");
}
if (request.Geofences?.Polygons is { Count: > 0 } polygons)
{
for (int i = 0; i < polygons.Count; i++)
{
ValidatePolygon(polygons[i], errors);
}
}
if (errors.Count > 0)
{
throw new ArgumentException(string.Join("; ", errors));
}
}
private static void ValidatePolygon(GeofencePolygon polygon, List<string> errors)
{
if (polygon.NorthWest is null || polygon.SouthEast is null)
{
errors.Add("Geofence polygon coordinates are required");
return;
}
var nw = polygon.NorthWest;
var se = polygon.SouthEast;
if ((Math.Abs(nw.Lat) < 0.0001 && Math.Abs(nw.Lon) < 0.0001) ||
(Math.Abs(se.Lat) < 0.0001 && Math.Abs(se.Lon) < 0.0001))
{
errors.Add("Geofence polygon coordinates cannot be (0,0)");
}
if (nw.Lat < -90 || nw.Lat > 90 ||
se.Lat < -90 || se.Lat > 90 ||
nw.Lon < -180 || nw.Lon > 180 ||
se.Lon < -180 || se.Lon > 180)
{
errors.Add("Geofence polygon coordinates must be valid (lat: -90 to 90, lon: -180 to 180)");
}
if (nw.Lat <= se.Lat)
{
errors.Add("Geofence northWest latitude must be greater than southEast latitude");
}
}
}