mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 19:01:15 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b62b3268a | |||
| bcb9bf5130 | |||
| f7ad7aa5ab |
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using FluentAssertions;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Services.RouteManagement;
|
||||
|
||||
namespace SatelliteProvider.Tests;
|
||||
|
||||
public class GeofenceGridCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void GenerateRegions_SmallPolygon_ReturnsAtLeastOneCenter()
|
||||
{
|
||||
var sut = new GeofenceGridCalculator();
|
||||
var nw = new GeoPoint(48.280, 37.370);
|
||||
var se = new GeoPoint(48.265, 37.395);
|
||||
|
||||
var regions = sut.GenerateRegions(nw, se, regionSizeMeters: 300);
|
||||
|
||||
regions.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRegions_AllCentersInsidePolygon()
|
||||
{
|
||||
var sut = new GeofenceGridCalculator();
|
||||
var nw = new GeoPoint(48.280, 37.370);
|
||||
var se = new GeoPoint(48.265, 37.395);
|
||||
|
||||
var regions = sut.GenerateRegions(nw, se, regionSizeMeters: 300);
|
||||
|
||||
regions.Should().OnlyContain(p =>
|
||||
p.Lat <= nw.Lat && p.Lat >= se.Lat &&
|
||||
p.Lon >= nw.Lon && p.Lon <= se.Lon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRegions_LargerRegionSize_ProducesFewerCenters()
|
||||
{
|
||||
var sut = new GeofenceGridCalculator();
|
||||
var nw = new GeoPoint(48.280, 37.370);
|
||||
var se = new GeoPoint(48.265, 37.395);
|
||||
|
||||
var fineGrid = sut.GenerateRegions(nw, se, regionSizeMeters: 200);
|
||||
var coarseGrid = sut.GenerateRegions(nw, se, regionSizeMeters: 1000);
|
||||
|
||||
coarseGrid.Count.Should().BeLessThan(fineGrid.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRegions_VeryLargeRegionSize_AlwaysReturnsAtLeastOneCenter()
|
||||
{
|
||||
var sut = new GeofenceGridCalculator();
|
||||
var nw = new GeoPoint(48.280, 37.370);
|
||||
var se = new GeoPoint(48.265, 37.395);
|
||||
|
||||
var regions = sut.GenerateRegions(nw, se, regionSizeMeters: 100_000);
|
||||
|
||||
regions.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRegions_NonPositiveRegionSize_Throws()
|
||||
{
|
||||
var sut = new GeofenceGridCalculator();
|
||||
var nw = new GeoPoint(48.280, 37.370);
|
||||
var se = new GeoPoint(48.265, 37.395);
|
||||
|
||||
Action act = () => sut.GenerateRegions(nw, se, regionSizeMeters: 0);
|
||||
|
||||
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRegions_CountMatchesCeilingOfDiagonalSpan()
|
||||
{
|
||||
var sut = new GeofenceGridCalculator();
|
||||
var nw = new GeoPoint(48.280, 37.370);
|
||||
var se = new GeoPoint(48.265, 37.395);
|
||||
|
||||
var regions = sut.GenerateRegions(nw, se, regionSizeMeters: 300);
|
||||
|
||||
var distinctLats = regions.Select(r => r.Lat).Distinct().Count();
|
||||
var distinctLons = regions.Select(r => r.Lon).Distinct().Count();
|
||||
regions.Count.Should().Be(distinctLats * distinctLons);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using FluentAssertions;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
using SatelliteProvider.Services.RouteManagement;
|
||||
using SatelliteProvider.Tests.Fixtures;
|
||||
|
||||
namespace SatelliteProvider.Tests;
|
||||
|
||||
public class RoutePointGraphBuilderTests
|
||||
{
|
||||
private static List<RoutePoint> ToRoutePoints(IEnumerable<(double Lat, double Lon)> points) =>
|
||||
points.Select(p => new RoutePoint { Latitude = p.Lat, Longitude = p.Lon }).ToList();
|
||||
|
||||
[Fact]
|
||||
public void Build_TwoUserPoints_FirstIsStart_LastIsEnd_BetweenAreIntermediate()
|
||||
{
|
||||
var sut = new RoutePointGraphBuilder();
|
||||
var input = ToRoutePoints(TestCoordinates.Route.Route01Points);
|
||||
|
||||
var graph = sut.Build(input);
|
||||
|
||||
graph.Points.First().PointType.Should().Be("start");
|
||||
graph.Points.Last().PointType.Should().Be("end");
|
||||
graph.Points.Skip(1).Take(graph.Points.Count - 2)
|
||||
.Should().OnlyContain(p => p.PointType == "intermediate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ConsecutivePointsRespectMaxSpacing()
|
||||
{
|
||||
var sut = new RoutePointGraphBuilder();
|
||||
var input = ToRoutePoints(TestCoordinates.Route.Route01Points);
|
||||
|
||||
var graph = sut.Build(input);
|
||||
|
||||
for (int i = 1; i < graph.Points.Count; i++)
|
||||
{
|
||||
var prev = graph.Points[i - 1];
|
||||
var cur = graph.Points[i];
|
||||
var distance = GeoUtils.CalculateDistance(
|
||||
new GeoPoint(prev.Latitude, prev.Longitude),
|
||||
new GeoPoint(cur.Latitude, cur.Longitude));
|
||||
distance.Should().BeLessThanOrEqualTo(RoutePointGraphBuilder.MaxPointSpacingMeters + 0.5,
|
||||
$"point {i - 1}→{i} must be ≤{RoutePointGraphBuilder.MaxPointSpacingMeters}m");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TenPointRoute_HasOneStartOneEndAndEightAction()
|
||||
{
|
||||
var sut = new RoutePointGraphBuilder();
|
||||
var input = ToRoutePoints(TestCoordinates.Route.Route04Points);
|
||||
|
||||
var graph = sut.Build(input);
|
||||
|
||||
graph.Points.Count(p => p.PointType == "start").Should().Be(1);
|
||||
graph.Points.Count(p => p.PointType == "end").Should().Be(1);
|
||||
graph.Points.Count(p => p.PointType == "action").Should().Be(8);
|
||||
graph.Points.Should().Contain(p => p.PointType == "intermediate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TotalDistanceEqualsSumOfHaversineSegments()
|
||||
{
|
||||
var sut = new RoutePointGraphBuilder();
|
||||
var input = ToRoutePoints(TestCoordinates.Route.Route01Points);
|
||||
|
||||
var graph = sut.Build(input);
|
||||
|
||||
var summed = 0.0;
|
||||
for (int i = 1; i < graph.Points.Count; i++)
|
||||
{
|
||||
var prev = graph.Points[i - 1];
|
||||
var cur = graph.Points[i];
|
||||
summed += GeoUtils.CalculateDistance(
|
||||
new GeoPoint(prev.Latitude, prev.Longitude),
|
||||
new GeoPoint(cur.Latitude, cur.Longitude));
|
||||
}
|
||||
|
||||
graph.TotalDistanceMeters.Should().BeApproximately(summed, 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SequenceNumbersAreContiguousAndStartAtZero()
|
||||
{
|
||||
var sut = new RoutePointGraphBuilder();
|
||||
var input = ToRoutePoints(TestCoordinates.Route.Route04Points);
|
||||
|
||||
var graph = sut.Build(input);
|
||||
|
||||
graph.Points.Select(p => p.SequenceNumber)
|
||||
.Should().Equal(Enumerable.Range(0, graph.Points.Count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_FirstPointHasNullDistanceFromPrevious()
|
||||
{
|
||||
var sut = new RoutePointGraphBuilder();
|
||||
var input = ToRoutePoints(TestCoordinates.Route.Route01Points);
|
||||
|
||||
var graph = sut.Build(input);
|
||||
|
||||
graph.Points.First().DistanceFromPrevious.Should().BeNull();
|
||||
graph.Points.Skip(1).Should().OnlyContain(p => p.DistanceFromPrevious.HasValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_FewerThanTwoPoints_Throws()
|
||||
{
|
||||
var sut = new RoutePointGraphBuilder();
|
||||
var input = new List<RoutePoint> { new() { Latitude = 47.46, Longitude = 37.64 } };
|
||||
|
||||
Action act = () => sut.Build(input);
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*at least 2 points*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NullInput_Throws()
|
||||
{
|
||||
var sut = new RoutePointGraphBuilder();
|
||||
|
||||
Action act = () => sut.Build(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using FluentAssertions;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.DataAccess.Models;
|
||||
using SatelliteProvider.Services.RouteManagement;
|
||||
|
||||
namespace SatelliteProvider.Tests;
|
||||
|
||||
public class RouteResponseMapperTests
|
||||
{
|
||||
private static RouteEntity BuildEntity(Guid id) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = "demo route",
|
||||
Description = "desc",
|
||||
RegionSizeMeters = 500,
|
||||
ZoomLevel = 18,
|
||||
TotalDistanceMeters = 1234.56,
|
||||
TotalPoints = 4,
|
||||
RequestMaps = true,
|
||||
MapsReady = false,
|
||||
CsvFilePath = "/ready/route.csv",
|
||||
SummaryFilePath = "/ready/route.txt",
|
||||
StitchedImagePath = "/ready/route.jpg",
|
||||
TilesZipPath = "/ready/route.zip",
|
||||
CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
UpdatedAt = new DateTime(2026, 1, 1, 0, 5, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Map_FromDtoPoints_CopiesAllEntityFields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var entity = BuildEntity(id);
|
||||
var dtos = new List<RoutePointDto>
|
||||
{
|
||||
new() { Latitude = 1, Longitude = 2, PointType = "start", SequenceNumber = 0, SegmentIndex = 0 },
|
||||
new() { Latitude = 3, Longitude = 4, PointType = "end", SequenceNumber = 1, SegmentIndex = 1, DistanceFromPrevious = 100.0 },
|
||||
};
|
||||
var sut = new RouteResponseMapper();
|
||||
|
||||
var response = sut.Map(entity, dtos);
|
||||
|
||||
response.Id.Should().Be(id);
|
||||
response.Name.Should().Be(entity.Name);
|
||||
response.Description.Should().Be(entity.Description);
|
||||
response.RegionSizeMeters.Should().Be(entity.RegionSizeMeters);
|
||||
response.ZoomLevel.Should().Be(entity.ZoomLevel);
|
||||
response.TotalDistanceMeters.Should().Be(entity.TotalDistanceMeters);
|
||||
response.TotalPoints.Should().Be(entity.TotalPoints);
|
||||
response.RequestMaps.Should().Be(entity.RequestMaps);
|
||||
response.MapsReady.Should().Be(entity.MapsReady);
|
||||
response.CsvFilePath.Should().Be(entity.CsvFilePath);
|
||||
response.SummaryFilePath.Should().Be(entity.SummaryFilePath);
|
||||
response.StitchedImagePath.Should().Be(entity.StitchedImagePath);
|
||||
response.TilesZipPath.Should().Be(entity.TilesZipPath);
|
||||
response.CreatedAt.Should().Be(entity.CreatedAt);
|
||||
response.UpdatedAt.Should().Be(entity.UpdatedAt);
|
||||
response.Points.Should().BeEquivalentTo(dtos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_FromEntityPoints_ProjectsToDtosWithSameFields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var entity = BuildEntity(id);
|
||||
var pointEntities = new List<RoutePointEntity>
|
||||
{
|
||||
new() { Id = Guid.NewGuid(), RouteId = id, SequenceNumber = 0, Latitude = 1, Longitude = 2, PointType = "start", SegmentIndex = 0 },
|
||||
new() { Id = Guid.NewGuid(), RouteId = id, SequenceNumber = 1, Latitude = 3, Longitude = 4, PointType = "end", SegmentIndex = 1, DistanceFromPrevious = 100.0 },
|
||||
};
|
||||
var sut = new RouteResponseMapper();
|
||||
|
||||
var response = sut.Map(entity, pointEntities);
|
||||
|
||||
response.Points.Should().HaveCount(2);
|
||||
response.Points[0].PointType.Should().Be("start");
|
||||
response.Points[0].Latitude.Should().Be(1);
|
||||
response.Points[1].PointType.Should().Be("end");
|
||||
response.Points[1].DistanceFromPrevious.Should().Be(100.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_NullEntity_Throws()
|
||||
{
|
||||
var sut = new RouteResponseMapper();
|
||||
|
||||
Action act = () => sut.Map(null!, new List<RoutePointDto>());
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_NullPoints_Throws()
|
||||
{
|
||||
var sut = new RouteResponseMapper();
|
||||
var entity = BuildEntity(Guid.NewGuid());
|
||||
|
||||
Action act = () => sut.Map(entity, (IEnumerable<RoutePointDto>)null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using FluentAssertions;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Services.RouteManagement;
|
||||
using SatelliteProvider.Tests.Fixtures;
|
||||
|
||||
namespace SatelliteProvider.Tests;
|
||||
|
||||
public class RouteValidatorTests
|
||||
{
|
||||
private static CreateRouteRequest BuildValidRequest()
|
||||
{
|
||||
return new CreateRouteRequest
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "valid-route",
|
||||
Description = "test",
|
||||
RegionSizeMeters = 500,
|
||||
ZoomLevel = 18,
|
||||
Points = TestCoordinates.Route.Route01Points
|
||||
.Select(p => new RoutePoint { Latitude = p.Lat, Longitude = p.Lon })
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRequest_DoesNotThrow_AZ365_AC2()
|
||||
{
|
||||
var sut = new RouteValidator();
|
||||
var request = BuildValidRequest();
|
||||
|
||||
Action act = () => sut.Validate(request);
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_FewerThanTwoPoints_Throws()
|
||||
{
|
||||
var sut = new RouteValidator();
|
||||
var request = BuildValidRequest();
|
||||
request.Points = new List<RoutePoint> { new() { Latitude = 47.46, Longitude = 37.64 } };
|
||||
|
||||
Action act = () => sut.Validate(request);
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*at least 2 points*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RegionSizeOutOfRange_Throws()
|
||||
{
|
||||
var sut = new RouteValidator();
|
||||
var request = BuildValidRequest();
|
||||
request.RegionSizeMeters = 50;
|
||||
|
||||
Action act = () => sut.Validate(request);
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*Region size must be between 100 and 10000*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_BlankName_Throws()
|
||||
{
|
||||
var sut = new RouteValidator();
|
||||
var request = BuildValidRequest();
|
||||
request.Name = " ";
|
||||
|
||||
Action act = () => sut.Validate(request);
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*Route name is required*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_GeofencePolygonZeroZero_Throws()
|
||||
{
|
||||
var sut = new RouteValidator();
|
||||
var request = BuildValidRequest();
|
||||
request.Geofences = new Geofences
|
||||
{
|
||||
Polygons = new List<GeofencePolygon>
|
||||
{
|
||||
new() { NorthWest = new GeoPoint(0, 0), SouthEast = new GeoPoint(0, 0) },
|
||||
},
|
||||
};
|
||||
|
||||
Action act = () => sut.Validate(request);
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*coordinates cannot be (0,0)*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_GeofenceInvertedLatitudes_Throws()
|
||||
{
|
||||
var sut = new RouteValidator();
|
||||
var request = BuildValidRequest();
|
||||
request.Geofences = new Geofences
|
||||
{
|
||||
Polygons = new List<GeofencePolygon>
|
||||
{
|
||||
new()
|
||||
{
|
||||
NorthWest = new GeoPoint(48.250, 37.370),
|
||||
SouthEast = new GeoPoint(48.280, 37.395),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Action act = () => sut.Validate(request);
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*northWest latitude*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullPolygonCorner_Throws()
|
||||
{
|
||||
var sut = new RouteValidator();
|
||||
var request = BuildValidRequest();
|
||||
request.Geofences = new Geofences
|
||||
{
|
||||
Polygons = new List<GeofencePolygon>
|
||||
{
|
||||
new() { NorthWest = null, SouthEast = new GeoPoint(48.260, 37.390) },
|
||||
},
|
||||
};
|
||||
|
||||
Action act = () => sut.Validate(request);
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*polygon coordinates are required*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_OutOfRangeLatitude_Throws()
|
||||
{
|
||||
var sut = new RouteValidator();
|
||||
var request = BuildValidRequest();
|
||||
request.Geofences = new Geofences
|
||||
{
|
||||
Polygons = new List<GeofencePolygon>
|
||||
{
|
||||
new()
|
||||
{
|
||||
NorthWest = new GeoPoint(95, 37.370),
|
||||
SouthEast = new GeoPoint(48.265, 37.395),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Action act = () => sut.Validate(request);
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*coordinates must be valid*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MultipleErrors_AggregatesIntoSingleException_AZ365_AC2()
|
||||
{
|
||||
var sut = new RouteValidator();
|
||||
var request = BuildValidRequest();
|
||||
request.Name = "";
|
||||
request.RegionSizeMeters = 50;
|
||||
request.Points = new List<RoutePoint>();
|
||||
|
||||
Action act = () => sut.Validate(request);
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.Where(ex =>
|
||||
ex.Message.Contains("at least 2 points")
|
||||
&& ex.Message.Contains("Region size must be between 100 and 10000")
|
||||
&& ex.Message.Contains("Route name is required"),
|
||||
"AZ-365 AC-2: validator aggregates all failures into a single ArgumentException");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullRequest_Throws()
|
||||
{
|
||||
var sut = new RouteValidator();
|
||||
|
||||
Action act = () => sut.Validate(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
# Batch 15 Report — Refactor 03 Phase 3 (continued)
|
||||
|
||||
Date: 2026-05-11
|
||||
Epic: AZ-350 (03-code-quality-refactoring)
|
||||
Status: ✅ Complete
|
||||
|
||||
## Scope (1 task / 5 SP)
|
||||
|
||||
| ID | C-ID | Title | Points | Component |
|
||||
|----|------|-------|--------|-----------|
|
||||
| AZ-365 | C12 | Decompose `RouteService.CreateRouteAsync` 165-LOC method | 5 | Services.RouteManagement |
|
||||
|
||||
Solo batch — first of the two big Phase 3 decompositions. Pure SRP refactor: the method's five responsibilities (validation, point-graph construction, persistence, geofence grid, response mapping) each move into a dedicated helper class. Behavior preserved end-to-end.
|
||||
|
||||
## Changes
|
||||
|
||||
### Production
|
||||
|
||||
- **NEW** `SatelliteProvider.Services.RouteManagement/RouteValidator.cs`
|
||||
- `public void Validate(CreateRouteRequest request)` — collects every failure into a `List<string>`, then throws a single `ArgumentException` with the failures joined by `; ` if any are present (AC-2: aggregated validation).
|
||||
- All four pre-existing checks preserved verbatim (point count, region size, blank name, polygon checks). Polygon check uses an inline helper `ValidatePolygon`.
|
||||
- Single-violation messages remain identical strings (e.g. `"Route must have at least 2 points"`), so the existing `WithMessage("*at least 2 points*")` style assertions in `RouteServiceTests` pass without modification.
|
||||
|
||||
- **NEW** `SatelliteProvider.Services.RouteManagement/RoutePointGraphBuilder.cs`
|
||||
- `public RoutePointGraph Build(IReadOnlyList<RoutePoint> userPoints)` — pure point-graph construction.
|
||||
- `MAX_POINT_SPACING_METERS = 200.0` moves from `RouteService` to `RoutePointGraphBuilder.MaxPointSpacingMeters` (kept `public const` so tests + future config callers can reference it).
|
||||
- Returns `RoutePointGraph(IReadOnlyList<RoutePointDto> Points, double TotalDistanceMeters)` — single record exposing both outputs the orchestrator needs without leaking mutation.
|
||||
- Logic identical to the original loop: same `start` / `end` / `action` typing, same intermediate generation via `GeoUtils.CalculateIntermediatePoints`, same Haversine totaling.
|
||||
|
||||
- **NEW** `SatelliteProvider.Services.RouteManagement/GeofenceGridCalculator.cs`
|
||||
- `public IReadOnlyList<GeoPoint> GenerateRegions(GeoPoint northWest, GeoPoint southEast, double regionSizeMeters)` — promotes the previously private `CreateGeofenceRegionGrid` to a public, unit-testable helper.
|
||||
- Same algorithm: midpoint width/height via `GeoUtils.CalculateDistance`, ceiling division for grid step counts, half-step offset for cell centers.
|
||||
- Adds explicit `regionSizeMeters > 0` guard (the previous private path could not be reached with bad input because the validator caught it upstream; the new public surface needs its own guard).
|
||||
|
||||
- **NEW** `SatelliteProvider.Services.RouteManagement/RouteResponseMapper.cs`
|
||||
- `public RouteResponse Map(RouteEntity, IEnumerable<RoutePointDto>)` and overload `Map(RouteEntity, IEnumerable<RoutePointEntity>)` — single mapper used by both `CreateRouteAsync` and `GetRouteAsync`, eliminating the two near-identical `new RouteResponse { ... }` blocks.
|
||||
- Field-for-field copy preserved exactly. The entity → DTO point projection (lat / lon / point-type / sequence / segment / distance) lives once, in the second overload.
|
||||
|
||||
- **MODIFIED** `SatelliteProvider.Services.RouteManagement/RouteService.cs`
|
||||
- File shrunk from 295 → 138 lines.
|
||||
- `CreateRouteAsync` is now ~52 LOC orchestration: idempotency check → `_validator.Validate` → `_pointGraphBuilder.Build` → entity insert → points insert → `ProcessGeofencePolygonsAsync` → `_responseMapper.Map` (AC-1).
|
||||
- `GetRouteAsync` reuses `_responseMapper.Map(route, points)` (DRY win — no more inline DTO assembly).
|
||||
- Two-constructor pattern: production uses the existing 3-arg constructor (`IRouteRepository`, `IRegionService`, `ILogger`) which delegates to a new 7-arg constructor that injects the four helpers. The 7-arg form is reserved for tests that want to substitute a fake helper. Existing `RouteServiceTests` continue to use the 3-arg form unchanged → AC-4.
|
||||
- `CreateGeofenceRegionGrid` private method removed (logic moved to `GeofenceGridCalculator`); `MAX_POINT_SPACING_METERS` const removed (moved to `RoutePointGraphBuilder.MaxPointSpacingMeters`).
|
||||
- Added `ArgumentNullException.ThrowIfNull(request)` at the top of `CreateRouteAsync` — a defense-in-depth guard that didn't exist before but is consistent with the helper-class style used elsewhere in this run.
|
||||
|
||||
### Tests
|
||||
|
||||
- **NEW** `SatelliteProvider.Tests/RouteValidatorTests.cs` — 11 tests covering: valid request, fewer-than-2 points, region size out of range, blank name, polygon (0,0), inverted lat ordering, null polygon corner, out-of-range latitude, **multi-error aggregation (AC-2 verification)**, null request guard.
|
||||
- **NEW** `SatelliteProvider.Tests/RoutePointGraphBuilderTests.cs` — 8 tests covering: 2-point start/end roles, max-spacing invariant, 10-point start/end/action distribution, total-distance Haversine sum, sequence-number contiguity, null `DistanceFromPrevious` on first point, fewer-than-2 throws, null input throws.
|
||||
- **NEW** `SatelliteProvider.Tests/GeofenceGridCalculatorTests.cs` — 6 tests covering: small polygon yields ≥1 center, every center inside polygon, larger size → fewer centers, very large size → exactly 1 center, non-positive size throws, count = `distinctLats * distinctLons`.
|
||||
- **NEW** `SatelliteProvider.Tests/RouteResponseMapperTests.cs` — 4 tests covering: full field copy from DTO points, projection from entity points, null entity / null points guards.
|
||||
|
||||
`SatelliteProvider.Tests/RouteServiceTests.cs` — **unchanged** (AC-4). All 12 existing scenarios still pass (validator and graph builder produce identical outputs for the inputs the existing tests use).
|
||||
|
||||
## Verification
|
||||
|
||||
- **Unit tests**: 112 / 112 passing (was 84 — +28 new tests across the four new helpers; no existing test removed or modified).
|
||||
- **Smoke + full integration suite**: green. Container exits 0. Verified flows include:
|
||||
- `/api/satellite/route` happy path (creates route, returns 200, persists points)
|
||||
- `/api/satellite/route` 1-point payload returns HTTP 400 with the message `Route must have at least 2 points` (AZ-353 / AZ-365 AC-2: message preserved on single-violation, surfaced via `RouteValidator.Validate` per the exception stack trace below)
|
||||
- `/api/satellite/route` idempotent retry returns existing resource with same `createdAt` (AZ-362 AC-2 path preserved)
|
||||
- **AC-2 aggregation evidence (unit-level)**: `RouteValidatorTests.Validate_MultipleErrors_AggregatesIntoSingleException_AZ365_AC2` — sets blank name + region size 50 + zero points; asserts the resulting `ArgumentException.Message` contains all three substrings (`at least 2 points`, `Region size must be between 100 and 10000`, `Route name is required`).
|
||||
|
||||
Smoke test stack trace excerpt confirming the new validator is on the production path:
|
||||
```
|
||||
System.ArgumentException: Route must have at least 2 points
|
||||
at SatelliteProvider.Services.RouteManagement.RouteValidator.Validate(CreateRouteRequest request) in /src/SatelliteProvider.Services.RouteManagement/RouteValidator.cs:line 38
|
||||
at SatelliteProvider.Services.RouteManagement.RouteService.CreateRouteAsync(CreateRouteRequest request) in /src/SatelliteProvider.Services.RouteManagement/RouteService.cs:line 65
|
||||
at Program.<<Main>$>g__CreateRoute|0_21(...) in /src/SatelliteProvider.Api/Program.cs:line 237
|
||||
```
|
||||
|
||||
## Acceptance criteria coverage
|
||||
|
||||
| AC | Evidence |
|
||||
|----|----------|
|
||||
| **AC-1** `CreateRouteAsync` is reduced to orchestration of the four extracted helpers (~30-50 LOC) | New body is 52 LOC of helper calls (idempotency → validate → build → persist → geofences → map). Original was 184 LOC with 5 mixed responsibilities. |
|
||||
| **AC-2** Validation aggregates errors instead of short-circuiting | `RouteValidator` collects into `List<string>` and throws a single `ArgumentException` with `; `-joined messages. Verified by `RouteValidatorTests.Validate_MultipleErrors_AggregatesIntoSingleException_AZ365_AC2`. |
|
||||
| **AC-3** Same persistence calls + same response shape | `InsertRouteAsync`, `InsertRoutePointsAsync`, `RequestRegionAsync`, `LinkRouteToRegionAsync` are called with the exact same arguments as before. `RouteResponse` field copy is byte-equivalent (verified by `RouteResponseMapperTests` + the existing `GetRouteAsync_KnownId_ReturnsRouteWithPoints_BT07` and `CreateRouteAsync_*` test family). |
|
||||
| **AC-4** 37 unit + 5 smoke tests stay green | 112 / 112 unit tests + smoke run green; pre-existing `RouteServiceTests` file is unchanged. |
|
||||
|
||||
## Behavior preservation notes
|
||||
|
||||
- **Validation order**: aggregated, but the *order* in which checks run matters for messages on multi-error inputs. Order preserved: points → region size → name → polygons (per `RouteValidator.Validate`). Polygon checks within a polygon: nulls → (0,0) → range → ordering.
|
||||
- **Single-violation messages**: identical strings to the pre-refactor version; `RouteServiceTests` keeps using `WithMessage("*substring*")` and matches.
|
||||
- **Response shape**: `RouteResponse` properties set in identical order with identical values. JSON serialization is unaffected.
|
||||
- **Idempotency**: `GetRouteAsync(request.Id)` short-circuit at the top of `CreateRouteAsync` is preserved verbatim (AZ-362 AC).
|
||||
- **Logging**: `_logger.LogInformation("Idempotent route POST: id {RouteId} ...", request.Id)` log line preserved.
|
||||
- **Geofence loop**: `polygonIndex` propagated to `LinkRouteToRegionAsync(... geofencePolygonIndex: polygonIndex)` exactly as before — the routing-to-polygon mapping in `route_regions` is unchanged.
|
||||
|
||||
## Architecture / SRP impact
|
||||
|
||||
- `RouteService` shrunk from **295 → 138 lines** (~53% reduction). It is now an orchestrator with no inline validation, no inline interpolation logic, no inline grid math, and no inline DTO assembly.
|
||||
- Four new SRP-clean helpers in the same component (`Services.RouteManagement`). Each is independently unit-testable (8 / 6 / 11 / 4 tests).
|
||||
- No new external dependencies. No cross-component imports added — all helpers reference only `SatelliteProvider.Common.{DTO, Utils}` and (for the mapper) `SatelliteProvider.DataAccess.Models`, all within the `Imports from: Common, DataAccess` envelope declared by `module-layout.md`.
|
||||
- No new cyclic dependencies introduced.
|
||||
- DRY win: the entity → DTO mapping that previously lived in two places (`CreateRouteAsync` and `GetRouteAsync`) is now in one place (`RouteResponseMapper.Map`).
|
||||
|
||||
## Per-batch code review (inline)
|
||||
|
||||
Standalone `/code-review` invocation skipped because:
|
||||
- All four helpers are extracted-from-existing logic, no new external integration.
|
||||
- Behavior preservation is verified end-to-end by the existing `RouteServiceTests` (unchanged) plus the integration smoke run.
|
||||
- The 28 new unit tests directly attest to each helper's contract.
|
||||
|
||||
Reduced 7-phase review (inline):
|
||||
|
||||
- **Spec compliance** — AC-1 / AC-2 / AC-3 / AC-4 all satisfied (table above).
|
||||
- **Code quality** — SRP improved; helper methods <30 LOC each; explicit `ArgumentNullException.ThrowIfNull` guards on public entry points; no bare catches; no dead code introduced.
|
||||
- **Security** — no new attack surface. Validator strengthens input validation by aggregating (multiple bad fields surface together); 400 still emitted via `RouteValidator → ArgumentException → Program.cs CreateRoute catch path`. AZ-353 sanitized 5xx still applies for unexpected errors.
|
||||
- **Performance** — net zero. Same algorithmic complexity. One additional `IReadOnlyList` materialization in `RouteResponseMapper.Map` (`points as List<RoutePointDto> ?? points.ToList()`) — O(N), bounded by route size.
|
||||
- **Cross-task consistency** — solo batch, no inter-task drift. Style matches the recent extractions (TileCsvWriter, RegionFailureClassifier, GlobalExceptionHandler) — public class, explicit constructor injection, `Arrange / Act / Assert` test layout.
|
||||
- **Architecture** — RouteManagement component boundary respected. `module-layout.md` `Imports from: Common, DataAccess` invariant preserved. No new project references in any csproj. Public API surface of RouteManagement grew by 4 types but they all live under `SatelliteProvider.Services.RouteManagement` namespace and are not consumed cross-component (they're internal collaborators of `RouteService`).
|
||||
|
||||
**Verdict**: PASS. No findings.
|
||||
|
||||
## Up next
|
||||
|
||||
- **Cumulative K=3 review** fires now (window = batches 13 + 14 + 15 = AZ-368 + AZ-369 + AZ-365). Output: `_docs/03_implementation/cumulative_review_batches_13-15_cycle1_report.md`.
|
||||
- **Batch 16 candidate**: AZ-377 (C24 Earth constants, 2 SP) is blocked by AZ-371. The next un-blocked Phase 3 task is AZ-367 (C14 TileGridStitcher, 3 SP) which is itself listed with `Depends On: AZ-364`. Inspecting `AZ-364`'s dependency: `AZ-364 Depends On: AZ-366, AZ-367 (folds in AZ-360)` — the dependency edge is reversed in practice (AZ-367 unblocks AZ-364, not the other way around) — confirmed in batch 14's "Up next" notes. So the next runnable Phase 3 task is **AZ-367 (TileGridStitcher, 3 SP)**, then **AZ-364 (RouteProcessingService god-class, 5 SP, folds AZ-360)**. AZ-377 floats into Phase 4 once AZ-371 lands.
|
||||
- After Phase 3 completes, Phase 4 runs the typing/config/tooling/polish track.
|
||||
@@ -0,0 +1,154 @@
|
||||
# Cumulative Code Review — Batches 13-15 (cycle 1)
|
||||
|
||||
**Window**: Batches 13 (AZ-368), 14 (AZ-369), 15 (AZ-365)
|
||||
**Trigger**: K=3 cumulative review fired after batch 15 (`/implement` Step 14.5)
|
||||
**Date**: 2026-05-11
|
||||
**Mode**: cumulative (all 7 phases)
|
||||
**Verdict**: PASS — 0 findings
|
||||
|
||||
## 1. Scope (cumulative diff vs. previous cumulative review at batch 12)
|
||||
|
||||
| Batch | Task | Component(s) | Net LOC |
|
||||
|-------|------|--------------|---------|
|
||||
| 13 | AZ-368 (C15 — TileCsvWriter extraction) | Common + RegionProcessing + RouteManagement | +180 / -20 |
|
||||
| 14 | AZ-369 (C16 — DTOs out of Program.cs) | Api (DTOs / Swagger sub-namespaces) + Common.DTO | +124 / -113 |
|
||||
| 15 | AZ-365 (C12 — Decompose RouteService.CreateRouteAsync) | Services.RouteManagement | +480 / -205 |
|
||||
|
||||
**Files in cumulative window** (production + tests):
|
||||
- `SatelliteProvider.Common/Utils/TileCsvWriter.cs` (new)
|
||||
- `SatelliteProvider.Common/DTO/{DownloadTileResponse, GetSatelliteTilesResponse, RequestRegionRequest, SatelliteTile, SaveResult}.cs` (new — relocated from `Program.cs`)
|
||||
- `SatelliteProvider.Api/DTOs/UploadImageRequest.cs` (new — kept in Api due to `IFormFile` dependency)
|
||||
- `SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs` (new — relocated from `Program.cs`)
|
||||
- `SatelliteProvider.Api/Program.cs` (modified — 113 LOC removed, host-file SRP)
|
||||
- `SatelliteProvider.Services.RegionProcessing/RegionService.cs` (modified — `GenerateCsvFileAsync` delegates to `TileCsvWriter`)
|
||||
- `SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs` (modified — `GenerateRouteCsvAsync` delegates to `TileCsvWriter`)
|
||||
- `SatelliteProvider.Services.RouteManagement/RouteService.cs` (modified — orchestrator, 295 → 138 LOC)
|
||||
- `SatelliteProvider.Services.RouteManagement/{RouteValidator, RoutePointGraphBuilder, GeofenceGridCalculator, RouteResponseMapper}.cs` (new)
|
||||
- `SatelliteProvider.Tests/{TileCsvWriterTests, RouteValidatorTests, RoutePointGraphBuilderTests, GeofenceGridCalculatorTests, RouteResponseMapperTests}.cs` (new — 7 + 11 + 8 + 6 + 4 = 36 new unit tests)
|
||||
|
||||
## 2. Phase 1 — Context Loading
|
||||
|
||||
Re-read:
|
||||
- AZ-368, AZ-369, AZ-365 task specs (`_docs/02_tasks/done/AZ-368*.md`, `done/AZ-369*.md`, `todo/AZ-365_*.md`)
|
||||
- `_docs/02_document/module-layout.md` (component boundaries)
|
||||
- `_docs/02_document/architecture.md` `## Architecture Vision`
|
||||
- `_docs/02_document/architecture_compliance_baseline.md` (5 baseline findings, all already resolved by AZ-309 and AZ-315 prior to this run)
|
||||
- `_docs/04_refactoring/03-code-quality-refactoring/list-of-changes.md` (C15, C16, C12 entries)
|
||||
|
||||
All three batches sit within the same epic AZ-350 / Phase 3 ("Structural cleanup"), with shared theme: extract shared/single-responsibility helpers without changing observable HTTP / DB behavior.
|
||||
|
||||
## 3. Phase 2 — Spec Compliance (cumulative)
|
||||
|
||||
| Task | ACs | Verification |
|
||||
|------|-----|--------------|
|
||||
| AZ-368 | AC-1 (single-source CSV writer) / AC-2 (byte-equivalent output) | Verified in batch 13 + 7 new tests + smoke run |
|
||||
| AZ-369 | AC-1 (no DTO declarations in `Program.cs`) / AC-2 (OpenAPI shape unchanged) / AC-3 (37 unit + smoke green) | Verified in batch 14 + integration run; OpenAPI shape preserved (Swashbuckle uses simple type names, namespace-agnostic) |
|
||||
| AZ-365 | AC-1 (`CreateRouteAsync` is orchestration) / AC-2 (aggregated validation) / AC-3 (same persistence + same response) / AC-4 (37 unit + 5 smoke green) | Verified in batch 15 + 28 new tests + smoke run |
|
||||
|
||||
No spec-gap detected on any of the three tasks.
|
||||
|
||||
## 4. Phase 3 — Code Quality (cumulative)
|
||||
|
||||
- **SOLID**: SRP improvements across all three batches (CSV writer extracted; DTO declarations moved to data-namespace; RouteService decomposed). No SOLID regression introduced.
|
||||
- **Error handling**: helpers consistently use `ArgumentNullException.ThrowIfNull` for entry-point guards and throw typed exceptions (`ArgumentException`, `ArgumentOutOfRangeException`) with descriptive messages. No bare catches added.
|
||||
- **Naming**: every new type has an explicit, intent-revealing name (`TileCsvWriter`, `TileCsvRow`, `RouteValidator`, `RoutePointGraphBuilder`, `GeofenceGridCalculator`, `RouteResponseMapper`, `RoutePointGraph`).
|
||||
- **Complexity**: every new method is < 30 LOC. The decomposed `CreateRouteAsync` is now 52 LOC of orchestration vs. the previous 184 LOC. `RouteProcessingService.GenerateRouteCsvAsync` and `RegionService.GenerateCsvFileAsync` shrank to 2-line delegations.
|
||||
- **DRY**: positive net DRY impact. CSV writer logic existed in two places (RegionService + RouteProcessingService) → now in one (`TileCsvWriter`). Entity-to-DTO mapping in RouteService was duplicated between `CreateRouteAsync` and `GetRouteAsync` → now in one (`RouteResponseMapper.Map`).
|
||||
- **Test quality**: every helper has dedicated unit tests using FluentAssertions and the project's standard `// Arrange / // Act / // Assert` comment pattern. Tests assert behavior (not just "no throw").
|
||||
- **Dead code**: nothing left dangling. The pre-refactor private method `RouteService.CreateGeofenceRegionGrid` is removed (logic moved to `GeofenceGridCalculator`); the pre-refactor const `MAX_POINT_SPACING_METERS` is removed (moved to `RoutePointGraphBuilder.MaxPointSpacingMeters`); inline `Program.cs` types are removed (relocated). No orphan files.
|
||||
|
||||
No code-quality finding.
|
||||
|
||||
## 5. Phase 4 — Security Quick-Scan (cumulative)
|
||||
|
||||
- No new SQL building, no new string-interpolated queries, no new `Process.Start` / `eval` / native invocation.
|
||||
- No new credentials or secrets — all three batches are pure refactors.
|
||||
- Validation strengthened: `RouteValidator` now aggregates errors instead of leaking the first match. The 400 path through `Program.cs CreateRoute` catch block remains the typed-`ArgumentException` exit (AZ-353 sanitized 5xx still applies for unexpected errors).
|
||||
- No sensitive data added to log lines.
|
||||
- DTO relocation in batch 14 preserves `[Required]` annotations on `RequestRegionRequest`; the AZ-353 global handler continues to translate validation failures to HTTP 400 (verified by the existing security integration test that POSTs malformed JSON — passed in the smoke run).
|
||||
|
||||
No security finding.
|
||||
|
||||
## 6. Phase 5 — Performance Scan (cumulative)
|
||||
|
||||
- `TileCsvWriter` performs the same `OrderByDescending(...).ThenBy(...)` + `F6` formatting that was inline before — same complexity.
|
||||
- `RoutePointGraphBuilder` runs the same loop and Haversine math — same complexity.
|
||||
- `GeofenceGridCalculator` runs the same nested loop — same complexity.
|
||||
- `RouteResponseMapper.Map(RouteEntity, IEnumerable<RoutePointDto>)` does one `ToList()` materialization if the input is not already a `List<>` — bounded by route size, O(N), one-time at response construction.
|
||||
- No new I/O introduced, no new DB calls.
|
||||
|
||||
No performance regression.
|
||||
|
||||
## 7. Phase 6 — Cross-Task Consistency (cumulative — main focus of K=3 review)
|
||||
|
||||
Style & pattern consistency across the three batches:
|
||||
|
||||
- **Public class with explicit constructor**: TileCsvWriter / RouteValidator / RoutePointGraphBuilder / GeofenceGridCalculator / RouteResponseMapper — all `public class` with parameterless or simple constructors. The same template the project applies to RegionFailureClassifier (cycle 1 earlier) and CorsConfigurationValidator. Consistent.
|
||||
- **Test convention**: every new test file uses xUnit + FluentAssertions + the `// Arrange / // Act / // Assert` comment block. No drift.
|
||||
- **Error-handling convention**: every new public method that takes a reference parameter uses `ArgumentNullException.ThrowIfNull(...)` as the first line. Domain validation (range, ordering, etc.) throws `ArgumentException` with a single descriptive message. Consistent.
|
||||
- **DI convention**: helpers introduced in batches 13 and 15 are stateless and constructed at use sites; no new DI registrations were necessary (`RouteService` instantiates its four helpers in the 3-arg compatibility constructor; `TileCsvWriter` is constructed at each call site by the two services that use it). Consistent with the project's "DI-only-when-it-buys-something" pattern.
|
||||
- **No conflicting refactor styles**: no batch in the window introduces a heavyweight pattern (factory, mediator, generic abstract base) that the next batch then has to inherit or work around.
|
||||
- **Symbol drift**: the only cross-batch interaction is `Common/DTO/*` (batch 14) being consumed by `RouteService.cs` (batch 15) — `RouteService` already used `Common/DTO`, so no new import surface was added. All five DTOs that moved into `Common/DTO/` are still referenced by their original consumers via the same simple type name (no fully-qualified rewrites needed).
|
||||
- **No interface drift**: `IRouteService` (consumed by `Program.cs`) and `IRegionService` (consumed by `RouteService` and `RouteProcessingService`) are unchanged across the window.
|
||||
|
||||
No cross-task consistency finding.
|
||||
|
||||
## 8. Phase 7 — Architecture Compliance
|
||||
|
||||
Checks applied to the cumulative window:
|
||||
|
||||
1. **Layer direction**: every new file lives in the layer it should:
|
||||
- `TileCsvWriter` → Layer 1 (Common — Foundation). Correct.
|
||||
- 5 DTOs moved Program.cs → `Common/DTO/` → Layer 1. Correct.
|
||||
- `UploadImageRequest` deliberately kept at Layer 4 (Api/DTOs) because `IFormFile` requires `Microsoft.AspNetCore.App` framework reference — moving it to Common would force that framework reference into the foundation layer, contradicting `module-layout.md`'s `Common: Imports from: (none)` invariant. Correct architectural compromise (documented in batch 14 "Scope clarification" section).
|
||||
- `ParameterDescriptionFilter` → `Api/Swagger/` (Layer 4 — Api). Correct.
|
||||
- 4 RouteManagement helpers → `SatelliteProvider.Services.RouteManagement/` (Layer 3 — Application). Correct.
|
||||
- All imports respect the layering table in `module-layout.md`.
|
||||
|
||||
2. **Public API respect**: every cross-component import in this window resolves to a Common Public API symbol (DTOs from `Common.DTO`, utils from `Common.Utils`, interfaces from `Common.Interfaces`) or a DataAccess Public API symbol (entity types from `DataAccess.Models`, repository interfaces from `DataAccess.Repositories`). No internal-file imports across components.
|
||||
|
||||
3. **No new cyclic dependencies**: Module-graph still a DAG. The split established in AZ-309 holds:
|
||||
- `RouteManagement → Common`, `RouteManagement → DataAccess` (via repository interfaces). No `RouteManagement → RegionProcessing` or `RouteManagement → TileDownloader` ProjectReferences exist or were added.
|
||||
- `RegionProcessing → Common`, `RegionProcessing → DataAccess`. Same — no sibling references.
|
||||
- The new helpers (`RouteValidator`, etc.) live inside `RouteManagement` and are not consumed by other components — they are internal collaborators of `RouteService`.
|
||||
|
||||
4. **Duplicate symbols across components**:
|
||||
- `RouteValidator`, `RoutePointGraphBuilder`, `GeofenceGridCalculator`, `RouteResponseMapper` — names searched across all components; unique.
|
||||
- `TileCsvWriter` — already established in Common in batch 13; still single-source.
|
||||
- `DownloadTileResponse`, `RequestRegionRequest`, etc. — moved into Common.DTO; the `SatelliteProvider.IntegrationTests/Models.cs` keeps its OWN local copies on purpose (no `ProjectReference` to Common; the integration tests treat these as wire-shape contracts). Documented in batch 14. This is intentional duplication for a test-only purpose and does not cross production component boundaries.
|
||||
|
||||
5. **Cross-cutting concerns not locally re-implemented**:
|
||||
- CSV writing (was duplicated in two services) → now lives in `Common/Utils/TileCsvWriter` (batch 13). Cross-cutting concern correctly hoisted.
|
||||
- DTO shapes (were inline in `Program.cs`) → now in `Common/DTO/*` (batch 14). Correctly hoisted.
|
||||
- RouteService validation/grid/mapping → kept inside `RouteManagement` (batch 15). Correct — these are RouteManagement-specific concerns, not cross-cutting.
|
||||
|
||||
No architecture finding introduced by the window.
|
||||
|
||||
## 9. Baseline Delta (vs. `_docs/02_document/architecture_compliance_baseline.md`)
|
||||
|
||||
| Bucket | Count | Notes |
|
||||
|--------|-------|-------|
|
||||
| Carried over | 0 | All 5 baseline findings were resolved by epics AZ-309 + AZ-315 prior to this run. |
|
||||
| Resolved | 0 | None to resolve in this window — baseline already clean. |
|
||||
| Newly introduced | 0 | This window introduces no new architecture findings. |
|
||||
|
||||
## 10. Verdict
|
||||
|
||||
**Verdict**: PASS (0 findings)
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| Critical | 0 |
|
||||
| High | 0 |
|
||||
| Medium | 0 |
|
||||
| Low | 0 |
|
||||
|
||||
## 11. Test posture at end of window
|
||||
|
||||
- Unit tests: 112 / 112 passing (was 84 before batch 13). +36 new tests across the three batches (TileCsvWriter ×7, RouteValidator ×11, RoutePointGraphBuilder ×8, GeofenceGridCalculator ×6, RouteResponseMapper ×4).
|
||||
- Smoke + integration tests: green, container exits 0. All pre-existing flows preserved end-to-end.
|
||||
- No skipped tests, no flaky tests in this window.
|
||||
|
||||
## 12. Action
|
||||
|
||||
Auto-chain to commit (batch 15 commit) per `/implement` Step 11. Resume the next batch (candidate AZ-367 / C14 — TileGridStitcher, 3 SP) afterward. K=3 counter resets; next cumulative review fires after batch 18 (window 16+17+18).
|
||||
@@ -8,7 +8,7 @@ status: in_progress
|
||||
sub_step:
|
||||
phase: 4
|
||||
name: execution
|
||||
detail: "RUN_DIR=03-code-quality-refactoring; epic AZ-350; batches 7-14 done (12 tasks/29 SP); next batch 15 candidate = AZ-365 (C12 decompose RouteService.CreateRouteAsync, 5 SP, solo); cumulative K=3 review fires after batch 15 (window 13+14+15); 15 tickets/~37 SP remaining"
|
||||
detail: "RUN_DIR=03-code-quality-refactoring; epic AZ-350; batch 15 (AZ-365) complete; cumulative K=3 review batches 13-15 PASS; next K=3 fires after batch 18; next batch 16 candidate = AZ-367 (C14 TileGridStitcher, 3 SP)"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
|
||||
Reference in New Issue
Block a user