mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 05:41:14 +00:00
[AZ-372] Apply dotnet format whitespace cleanup; archive batch 22
Pure whitespace-only cleanup uncovered by the new format gate from the previous commit. Verified via `git diff -w --stat`: only 4 files differ when whitespace is ignored, and those differ only by the BOM byte. Cleanup kinds applied across 22 source files: - BOM removal (MapConfig.cs, SatTile.cs, GeoUtils.cs, IntegrationTests/Program.cs) - CRLF -> LF (IntegrationTests/Program.cs) - Trailing whitespace on blank lines (Common, Api, DataAccess, IntegrationTests, Services.RegionProcessing, Services.TileDownloader) - Final newline added (RoutePoint.cs, GeoPoint.cs, others) After this commit `dotnet format whitespace SatelliteProvider.sln --verify-no-changes` exits 0; AC-1 is enforceable from `scripts/ run-tests.sh` going forward. Also lands the batch 22 report, code-review report (PASS_WITH_WARNINGS, 2 Low findings — both deferred per spec), dependency-table status update (AZ-372 -> Done (In Testing)), task archive (todo/ -> done/), and autodev state update. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,10 +17,10 @@ using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((context, configuration) =>
|
||||
builder.Host.UseSerilog((context, configuration) =>
|
||||
configuration.ReadFrom.Configuration(context.Configuration));
|
||||
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
|
||||
|
||||
DapperEnumTypeHandlers.RegisterAll();
|
||||
@@ -69,7 +69,7 @@ builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Satellite Provider API", Version = "v1" });
|
||||
|
||||
|
||||
c.MapType<UploadImageRequest>(() => new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
@@ -86,7 +86,7 @@ builder.Services.AddSwaggerGen(c =>
|
||||
},
|
||||
Required = new HashSet<string> { "timestamp", "image", "lat", "lon", "height", "focalLength", "sensorWidth", "sensorHeight" }
|
||||
});
|
||||
|
||||
|
||||
c.OperationFilter<ParameterDescriptionFilter>();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace SatelliteProvider.Common.Configs;
|
||||
namespace SatelliteProvider.Common.Configs;
|
||||
|
||||
public class MapConfig
|
||||
{
|
||||
public string Service { get; set; } = null!;
|
||||
public string ApiKey { get; set; } = null!;
|
||||
public string ApiKey { get; set; } = null!;
|
||||
|
||||
// AZ-371 / C18 — Google Maps tile constants promoted from source literals.
|
||||
public int TileSizePixels { get; set; } = 256;
|
||||
|
||||
@@ -9,12 +9,12 @@ public class CreateRouteRequest
|
||||
public string? Description { get; set; }
|
||||
public double RegionSizeMeters { get; set; }
|
||||
public int ZoomLevel { get; set; }
|
||||
|
||||
|
||||
public List<RoutePoint> Points { get; set; } = new();
|
||||
|
||||
|
||||
[JsonPropertyName("geofences")]
|
||||
public Geofences? Geofences { get; set; }
|
||||
|
||||
|
||||
public bool RequestMaps { get; set; } = false;
|
||||
public bool CreateTilesZip { get; set; } = false;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ namespace SatelliteProvider.Common.DTO;
|
||||
public class GeoPoint
|
||||
{
|
||||
const double PRECISION_TOLERANCE = 0.00005;
|
||||
|
||||
|
||||
[JsonPropertyName("lat")]
|
||||
public double Lat { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("lon")]
|
||||
public double Lon { get; set; }
|
||||
|
||||
@@ -35,4 +35,4 @@ public class GeoPoint
|
||||
|
||||
public static bool operator ==(GeoPoint left, GeoPoint right) => Equals(left, right);
|
||||
public static bool operator !=(GeoPoint left, GeoPoint right) => !Equals(left, right);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ public class GeofencePolygon
|
||||
{
|
||||
[JsonPropertyName("northWest")]
|
||||
public GeoPoint? NorthWest { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("southEast")]
|
||||
public GeoPoint? SouthEast { get; set; }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ public class RoutePoint
|
||||
{
|
||||
[JsonPropertyName("lat")]
|
||||
public double Latitude { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("lon")]
|
||||
public double Longitude { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using SatelliteProvider.Common.Utils;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
|
||||
namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
@@ -29,4 +29,4 @@ public class SatTile
|
||||
{
|
||||
return $"Tile[X={X}, Y={Y}, TL=({LeftTop.Lat:F6}, {LeftTop.Lon:F6}), BR=({BottomRight.Lat:F6}, {BottomRight.Lon:F6})]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,4 @@ public interface ISatelliteDownloader
|
||||
GeoPoint centerGeoPoint, double radiusM, int zoomLevel,
|
||||
IEnumerable<ExistingTileInfo> existingTiles,
|
||||
CancellationToken token = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Common.Utils;
|
||||
|
||||
@@ -88,7 +88,7 @@ public static class GeoUtils
|
||||
{
|
||||
var direction = start.DirectionTo(end);
|
||||
var distance = direction.Distance;
|
||||
|
||||
|
||||
if (distance <= maxSpacingMeters)
|
||||
{
|
||||
return new List<GeoPoint>();
|
||||
@@ -96,9 +96,9 @@ public static class GeoUtils
|
||||
|
||||
var numSegments = (int)Math.Ceiling(distance / maxSpacingMeters);
|
||||
var actualSpacing = distance / numSegments;
|
||||
|
||||
|
||||
var intermediatePoints = new List<GeoPoint>();
|
||||
|
||||
|
||||
for (int i = 1; i < numSegments; i++)
|
||||
{
|
||||
var segmentDistance = actualSpacing * i;
|
||||
@@ -110,7 +110,7 @@ public static class GeoUtils
|
||||
var intermediatePoint = start.GoDirection(intermediateDirection);
|
||||
intermediatePoints.Add(intermediatePoint);
|
||||
}
|
||||
|
||||
|
||||
return intermediatePoints;
|
||||
}
|
||||
|
||||
@@ -130,4 +130,4 @@ public static class GeoUtils
|
||||
{
|
||||
return CalculateDistance(northWest, southEast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public class DatabaseMigrator
|
||||
|
||||
var upgrader = DeployChanges.To
|
||||
.PostgresqlDatabase(_connectionString)
|
||||
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly(),
|
||||
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly(),
|
||||
script => script.Contains(".Migrations."))
|
||||
.LogToConsole()
|
||||
.Build();
|
||||
|
||||
@@ -29,7 +29,7 @@ public class RegionRepository : IRegionRepository
|
||||
created_at as CreatedAt, updated_at as UpdatedAt
|
||||
FROM regions
|
||||
WHERE id = @Id";
|
||||
|
||||
|
||||
var region = await connection.QuerySingleOrDefaultAsync<RegionEntity>(sql, new { Id = id });
|
||||
return region;
|
||||
}
|
||||
@@ -47,7 +47,7 @@ public class RegionRepository : IRegionRepository
|
||||
FROM regions
|
||||
WHERE status = @Status
|
||||
ORDER BY created_at ASC";
|
||||
|
||||
|
||||
return await connection.QueryAsync<RegionEntity>(sql, new { Status = status });
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ public class RegionRepository : IRegionRepository
|
||||
@TilesDownloaded, @TilesReused, @StitchTiles,
|
||||
@CreatedAt, @UpdatedAt)
|
||||
RETURNING id";
|
||||
|
||||
|
||||
return await connection.ExecuteScalarAsync<Guid>(sql, region);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ public class RegionRepository : IRegionRepository
|
||||
stitch_tiles = @StitchTiles,
|
||||
updated_at = @UpdatedAt
|
||||
WHERE id = @Id";
|
||||
|
||||
|
||||
return await connection.ExecuteAsync(sql, region);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ public class RouteRepository : IRouteRepository
|
||||
created_at as CreatedAt, updated_at as UpdatedAt
|
||||
FROM routes
|
||||
WHERE id = @Id";
|
||||
|
||||
|
||||
return await connection.QuerySingleOrDefaultAsync<RouteEntity>(sql, new { Id = id });
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class RouteRepository : IRouteRepository
|
||||
FROM route_points
|
||||
WHERE route_id = @RouteId
|
||||
ORDER BY sequence_number";
|
||||
|
||||
|
||||
var points = (await connection.QueryAsync<RoutePointEntity>(sql, new { RouteId = routeId })).ToList();
|
||||
return points;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ public class RouteRepository : IRouteRepository
|
||||
@CreateTilesZip, @CsvFilePath, @SummaryFilePath, @StitchedImagePath,
|
||||
@TilesZipPath, @CreatedAt, @UpdatedAt)
|
||||
RETURNING id";
|
||||
|
||||
|
||||
return await connection.ExecuteScalarAsync<Guid>(sql, route);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ public class RouteRepository : IRouteRepository
|
||||
point_type, segment_index, distance_from_previous, created_at)
|
||||
VALUES (@Id, @RouteId, @SequenceNumber, @Latitude, @Longitude,
|
||||
@PointType, @SegmentIndex, @DistanceFromPrevious, @CreatedAt)";
|
||||
|
||||
|
||||
var pointsList = points.ToList();
|
||||
await connection.ExecuteAsync(sql, pointsList);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ public class RouteRepository : IRouteRepository
|
||||
tiles_zip_path = @TilesZipPath,
|
||||
updated_at = @UpdatedAt
|
||||
WHERE id = @Id";
|
||||
|
||||
|
||||
return await connection.ExecuteAsync(sql, route);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ public class RouteRepository : IRouteRepository
|
||||
INSERT INTO route_regions (route_id, region_id, is_geofence, geofence_polygon_index, created_at)
|
||||
VALUES (@RouteId, @RegionId, @IsGeofence, @GeofencePolygonIndex, @CreatedAt)
|
||||
ON CONFLICT (route_id, region_id) DO NOTHING";
|
||||
|
||||
|
||||
await connection.ExecuteAsync(sql, new { RouteId = routeId, RegionId = regionId, IsGeofence = isGeofence, GeofencePolygonIndex = geofencePolygonIndex, CreatedAt = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ public class RouteRepository : IRouteRepository
|
||||
SELECT region_id
|
||||
FROM route_regions
|
||||
WHERE route_id = @RouteId AND (is_geofence = false OR is_geofence IS NULL)";
|
||||
|
||||
|
||||
return await connection.QueryAsync<Guid>(sql, new { RouteId = routeId });
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ public class RouteRepository : IRouteRepository
|
||||
SELECT region_id
|
||||
FROM route_regions
|
||||
WHERE route_id = @RouteId AND is_geofence = true";
|
||||
|
||||
|
||||
return await connection.QueryAsync<Guid>(sql, new { RouteId = routeId });
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ public class RouteRepository : IRouteRepository
|
||||
created_at as CreatedAt, updated_at as UpdatedAt
|
||||
FROM routes
|
||||
WHERE request_maps = true AND maps_ready = false";
|
||||
|
||||
|
||||
return await connection.QueryAsync<RouteEntity>(sql);
|
||||
}
|
||||
|
||||
@@ -169,9 +169,9 @@ public class RouteRepository : IRouteRepository
|
||||
FROM route_regions
|
||||
WHERE route_id = @RouteId AND is_geofence = true AND geofence_polygon_index IS NOT NULL
|
||||
ORDER BY geofence_polygon_index";
|
||||
|
||||
|
||||
var results = await connection.QueryAsync<(Guid RegionId, int PolygonIndex)>(sql, new { RouteId = routeId });
|
||||
|
||||
|
||||
var grouped = new Dictionary<int, List<Guid>>();
|
||||
foreach (var (regionId, polygonIndex) in results)
|
||||
{
|
||||
@@ -181,7 +181,7 @@ public class RouteRepository : IRouteRepository
|
||||
}
|
||||
grouped[polygonIndex].Add(regionId);
|
||||
}
|
||||
|
||||
|
||||
return grouped;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public class TileRepository : ITileRepository
|
||||
file_path as FilePath, created_at as CreatedAt, updated_at as UpdatedAt
|
||||
FROM tiles
|
||||
WHERE id = @Id";
|
||||
|
||||
|
||||
return await connection.QuerySingleOrDefaultAsync<TileEntity>(sql, new { Id = id });
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class TileRepository : ITileRepository
|
||||
WHERE tile_zoom = @TileZoom AND tile_x = @TileX AND tile_y = @TileY
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1";
|
||||
|
||||
|
||||
return await connection.QuerySingleOrDefaultAsync<TileEntity>(sql, new { TileZoom = tileZoom, TileX = tileX, TileY = tileY });
|
||||
}
|
||||
|
||||
@@ -64,11 +64,11 @@ public class TileRepository : ITileRepository
|
||||
AND tile_zoom = @TileZoom
|
||||
AND version = @Version
|
||||
LIMIT 1";
|
||||
|
||||
return await connection.QuerySingleOrDefaultAsync<TileEntity>(sql, new
|
||||
{
|
||||
Latitude = latitude,
|
||||
Longitude = longitude,
|
||||
|
||||
return await connection.QuerySingleOrDefaultAsync<TileEntity>(sql, new
|
||||
{
|
||||
Latitude = latitude,
|
||||
Longitude = longitude,
|
||||
TileSizeMeters = tileSizeMeters,
|
||||
TileZoom = zoomLevel,
|
||||
Version = version
|
||||
@@ -78,18 +78,18 @@ public class TileRepository : ITileRepository
|
||||
public async Task<IEnumerable<TileEntity>> GetTilesByRegionAsync(double latitude, double longitude, double sizeMeters, int zoomLevel)
|
||||
{
|
||||
using var connection = new NpgsqlConnection(_connectionString);
|
||||
|
||||
|
||||
const double EARTH_CIRCUMFERENCE_METERS = 40075016.686;
|
||||
const int TILE_SIZE_PIXELS = 256;
|
||||
var latRad = latitude * Math.PI / 180.0;
|
||||
var metersPerPixel = (EARTH_CIRCUMFERENCE_METERS * Math.Cos(latRad)) / (Math.Pow(2, zoomLevel) * TILE_SIZE_PIXELS);
|
||||
var tileSizeMeters = metersPerPixel * TILE_SIZE_PIXELS;
|
||||
|
||||
|
||||
var expandedSizeMeters = sizeMeters + (tileSizeMeters * 2);
|
||||
|
||||
|
||||
var latRange = expandedSizeMeters / 111000.0;
|
||||
var lonRange = expandedSizeMeters / (111000.0 * Math.Cos(latitude * Math.PI / 180.0));
|
||||
|
||||
|
||||
const string sql = @"
|
||||
SELECT id, tile_zoom as TileZoom, tile_x as TileX, tile_y as TileY,
|
||||
latitude, longitude,
|
||||
@@ -101,7 +101,7 @@ public class TileRepository : ITileRepository
|
||||
AND longitude BETWEEN @MinLon AND @MaxLon
|
||||
AND tile_zoom = @TileZoom
|
||||
ORDER BY latitude DESC, longitude ASC, updated_at DESC";
|
||||
|
||||
|
||||
return await connection.QueryAsync<TileEntity>(sql, new
|
||||
{
|
||||
MinLat = latitude - latRange / 2,
|
||||
@@ -129,7 +129,7 @@ public class TileRepository : ITileRepository
|
||||
tile_y = EXCLUDED.tile_y,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id";
|
||||
|
||||
|
||||
return await connection.ExecuteScalarAsync<Guid>(sql, tile);
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ public class TileRepository : ITileRepository
|
||||
file_path = @FilePath,
|
||||
updated_at = @UpdatedAt
|
||||
WHERE id = @Id";
|
||||
|
||||
|
||||
return await connection.ExecuteAsync(sql, tile);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ public static class BasicRouteTests
|
||||
|
||||
Console.WriteLine("Retrieving route by ID...");
|
||||
var getResponse = await httpClient.GetAsync($"/api/satellite/route/{routeId}");
|
||||
|
||||
|
||||
if (!getResponse.IsSuccessStatusCode)
|
||||
{
|
||||
throw new Exception($"Failed to retrieve route: {getResponse.StatusCode}");
|
||||
@@ -70,7 +70,7 @@ public static class BasicRouteTests
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
|
||||
if (retrievedRoute == null || retrievedRoute.Id != routeId)
|
||||
{
|
||||
throw new Exception("Retrieved route does not match created route");
|
||||
|
||||
@@ -148,7 +148,7 @@ public static class ExtendedRouteTests
|
||||
using (var zipArchive = System.IO.Compression.ZipFile.OpenRead(finalRoute.TilesZipPath!))
|
||||
{
|
||||
Console.WriteLine($" ZIP contains {zipArchive.Entries.Count} files");
|
||||
|
||||
|
||||
if (zipArchive.Entries.Count == 0)
|
||||
{
|
||||
throw new Exception("ZIP file is empty");
|
||||
@@ -161,7 +161,7 @@ public static class ExtendedRouteTests
|
||||
|
||||
var firstEntry = zipArchive.Entries[0];
|
||||
Console.WriteLine($" First entry: {firstEntry.FullName} ({firstEntry.Length} bytes)");
|
||||
|
||||
|
||||
if (firstEntry.Length == 0)
|
||||
{
|
||||
throw new Exception("First entry in ZIP is empty");
|
||||
@@ -169,7 +169,7 @@ public static class ExtendedRouteTests
|
||||
|
||||
var entriesWithDirs = zipArchive.Entries.Where(e => e.FullName.Contains('/')).ToList();
|
||||
Console.WriteLine($" Entries with directory structure: {entriesWithDirs.Count}/{zipArchive.Entries.Count}");
|
||||
|
||||
|
||||
if (entriesWithDirs.Count == 0)
|
||||
{
|
||||
throw new Exception("ZIP should preserve directory structure but found no entries with paths");
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task<int> Main(string[] args)
|
||||
{
|
||||
var apiUrl = Environment.GetEnvironmentVariable("API_URL") ?? "http://api:8080";
|
||||
var modeEnv = Environment.GetEnvironmentVariable("INTEGRATION_TESTS_MODE")?.Trim().ToLowerInvariant();
|
||||
var modeArg = args.FirstOrDefault(a => a.Equals("--smoke", StringComparison.OrdinalIgnoreCase) || a.Equals("--full", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (modeArg != null)
|
||||
{
|
||||
TestRunMode.Smoke = modeArg.Equals("--smoke", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(modeEnv))
|
||||
{
|
||||
TestRunMode.Smoke = modeEnv == "smoke";
|
||||
}
|
||||
|
||||
Console.WriteLine("Starting Integration Tests");
|
||||
Console.WriteLine("=========================");
|
||||
Console.WriteLine($"API URL : {apiUrl}");
|
||||
Console.WriteLine($"Mode : {(TestRunMode.Smoke ? "smoke (fast subset, tightened timeouts)" : "full")}");
|
||||
Console.WriteLine();
|
||||
|
||||
using var httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(apiUrl),
|
||||
Timeout = TimeSpan.FromMinutes(15)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine("Waiting for API to be ready...");
|
||||
await WaitForApiReady(httpClient);
|
||||
Console.WriteLine("✓ API is ready");
|
||||
Console.WriteLine();
|
||||
|
||||
if (TestRunMode.Smoke)
|
||||
{
|
||||
await RunSmokeSuite(httpClient);
|
||||
}
|
||||
else
|
||||
{
|
||||
await RunFullSuite(httpClient);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=========================");
|
||||
Console.WriteLine("All tests completed successfully!");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("❌ Integration tests failed");
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Console.WriteLine($"Stack trace: {ex.StackTrace}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunSmokeSuite(HttpClient httpClient)
|
||||
{
|
||||
await TileTests.RunGetTileByLatLonTest(httpClient);
|
||||
await RegionTests.RunRegionProcessingTest_200m_Zoom18(httpClient);
|
||||
await BasicRouteTests.RunSimpleRouteTest(httpClient);
|
||||
await ExtendedRouteTests.RunRouteWithTilesZipTest(httpClient);
|
||||
await SecurityTests.RunAll(httpClient);
|
||||
await StubAndErrorContractTests.RunAll(httpClient);
|
||||
await IdempotentPostTests.RunAll(httpClient);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
|
||||
static async Task RunFullSuite(HttpClient httpClient)
|
||||
{
|
||||
await TileTests.RunGetTileByLatLonTest(httpClient);
|
||||
|
||||
await RegionTests.RunRegionProcessingTest_200m_Zoom18(httpClient);
|
||||
await RegionTests.RunRegionProcessingTest_400m_Zoom17(httpClient);
|
||||
await RegionTests.RunRegionProcessingTest_500m_Zoom18(httpClient);
|
||||
|
||||
await BasicRouteTests.RunSimpleRouteTest(httpClient);
|
||||
await BasicRouteTests.RunRouteWithRegionProcessingAndStitching(httpClient);
|
||||
await ExtendedRouteTests.RunRouteWithTilesZipTest(httpClient);
|
||||
await ComplexRouteTests.RunComplexRouteWithStitching(httpClient);
|
||||
await ComplexRouteTests.RunComplexRouteWithStitchingAndGeofences(httpClient);
|
||||
await ExtendedRouteTests.RunExtendedRouteEast(httpClient);
|
||||
|
||||
await SecurityTests.RunAll(httpClient);
|
||||
await StubAndErrorContractTests.RunAll(httpClient);
|
||||
await IdempotentPostTests.RunAll(httpClient);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
|
||||
static async Task WaitForApiReady(HttpClient httpClient, int maxRetries = 30)
|
||||
{
|
||||
for (int i = 0; i < maxRetries; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await httpClient.GetAsync("/");
|
||||
if (response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
Console.WriteLine($" Attempt {i + 1}/{maxRetries} - waiting 2 seconds...");
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
|
||||
throw new Exception("API did not become ready in time");
|
||||
}
|
||||
}
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task<int> Main(string[] args)
|
||||
{
|
||||
var apiUrl = Environment.GetEnvironmentVariable("API_URL") ?? "http://api:8080";
|
||||
var modeEnv = Environment.GetEnvironmentVariable("INTEGRATION_TESTS_MODE")?.Trim().ToLowerInvariant();
|
||||
var modeArg = args.FirstOrDefault(a => a.Equals("--smoke", StringComparison.OrdinalIgnoreCase) || a.Equals("--full", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (modeArg != null)
|
||||
{
|
||||
TestRunMode.Smoke = modeArg.Equals("--smoke", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(modeEnv))
|
||||
{
|
||||
TestRunMode.Smoke = modeEnv == "smoke";
|
||||
}
|
||||
|
||||
Console.WriteLine("Starting Integration Tests");
|
||||
Console.WriteLine("=========================");
|
||||
Console.WriteLine($"API URL : {apiUrl}");
|
||||
Console.WriteLine($"Mode : {(TestRunMode.Smoke ? "smoke (fast subset, tightened timeouts)" : "full")}");
|
||||
Console.WriteLine();
|
||||
|
||||
using var httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(apiUrl),
|
||||
Timeout = TimeSpan.FromMinutes(15)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine("Waiting for API to be ready...");
|
||||
await WaitForApiReady(httpClient);
|
||||
Console.WriteLine("✓ API is ready");
|
||||
Console.WriteLine();
|
||||
|
||||
if (TestRunMode.Smoke)
|
||||
{
|
||||
await RunSmokeSuite(httpClient);
|
||||
}
|
||||
else
|
||||
{
|
||||
await RunFullSuite(httpClient);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=========================");
|
||||
Console.WriteLine("All tests completed successfully!");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("❌ Integration tests failed");
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Console.WriteLine($"Stack trace: {ex.StackTrace}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunSmokeSuite(HttpClient httpClient)
|
||||
{
|
||||
await TileTests.RunGetTileByLatLonTest(httpClient);
|
||||
await RegionTests.RunRegionProcessingTest_200m_Zoom18(httpClient);
|
||||
await BasicRouteTests.RunSimpleRouteTest(httpClient);
|
||||
await ExtendedRouteTests.RunRouteWithTilesZipTest(httpClient);
|
||||
await SecurityTests.RunAll(httpClient);
|
||||
await StubAndErrorContractTests.RunAll(httpClient);
|
||||
await IdempotentPostTests.RunAll(httpClient);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
|
||||
static async Task RunFullSuite(HttpClient httpClient)
|
||||
{
|
||||
await TileTests.RunGetTileByLatLonTest(httpClient);
|
||||
|
||||
await RegionTests.RunRegionProcessingTest_200m_Zoom18(httpClient);
|
||||
await RegionTests.RunRegionProcessingTest_400m_Zoom17(httpClient);
|
||||
await RegionTests.RunRegionProcessingTest_500m_Zoom18(httpClient);
|
||||
|
||||
await BasicRouteTests.RunSimpleRouteTest(httpClient);
|
||||
await BasicRouteTests.RunRouteWithRegionProcessingAndStitching(httpClient);
|
||||
await ExtendedRouteTests.RunRouteWithTilesZipTest(httpClient);
|
||||
await ComplexRouteTests.RunComplexRouteWithStitching(httpClient);
|
||||
await ComplexRouteTests.RunComplexRouteWithStitchingAndGeofences(httpClient);
|
||||
await ExtendedRouteTests.RunExtendedRouteEast(httpClient);
|
||||
|
||||
await SecurityTests.RunAll(httpClient);
|
||||
await StubAndErrorContractTests.RunAll(httpClient);
|
||||
await IdempotentPostTests.RunAll(httpClient);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
|
||||
static async Task WaitForApiReady(HttpClient httpClient, int maxRetries = 30)
|
||||
{
|
||||
for (int i = 0; i < maxRetries; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await httpClient.GetAsync("/");
|
||||
if (response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
Console.WriteLine($" Attempt {i + 1}/{maxRetries} - waiting 2 seconds...");
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
|
||||
throw new Exception("API did not become ready in time");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ public static class RegionTests
|
||||
};
|
||||
|
||||
var requestResponse = await httpClient.PostAsJsonAsync("/api/satellite/request", requestRegion);
|
||||
|
||||
|
||||
if (!requestResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await requestResponse.Content.ReadAsStringAsync();
|
||||
@@ -100,7 +100,7 @@ public static class RegionTests
|
||||
}
|
||||
|
||||
var initialStatus = await requestResponse.Content.ReadFromJsonAsync<RegionStatusResponse>(JsonOptions);
|
||||
|
||||
|
||||
if (initialStatus == null)
|
||||
{
|
||||
throw new Exception("No status returned from region request");
|
||||
@@ -113,13 +113,13 @@ public static class RegionTests
|
||||
Console.WriteLine("Polling for region status updates...");
|
||||
RegionStatusResponse? finalStatus = null;
|
||||
int maxAttempts = TestRunMode.RegionPollAttempts;
|
||||
|
||||
|
||||
for (int i = 0; i < maxAttempts; i++)
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
|
||||
|
||||
var statusResponse = await httpClient.GetAsync($"/api/satellite/region/{regionId}");
|
||||
|
||||
|
||||
if (!statusResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await statusResponse.Content.ReadAsStringAsync();
|
||||
@@ -127,7 +127,7 @@ public static class RegionTests
|
||||
}
|
||||
|
||||
var status = await statusResponse.Content.ReadFromJsonAsync<RegionStatusResponse>(JsonOptions);
|
||||
|
||||
|
||||
if (status == null)
|
||||
{
|
||||
throw new Exception("No status returned");
|
||||
|
||||
@@ -105,12 +105,12 @@ public static class RouteTestHelpers
|
||||
public static void PrintGeneratedFiles(RouteResponseModel route, int uniqueTileCount, bool includeZip = false)
|
||||
{
|
||||
var stitchedInfo = new FileInfo(route.StitchedImagePath!);
|
||||
|
||||
|
||||
Console.WriteLine("Files Generated:");
|
||||
Console.WriteLine($" ✓ CSV: {Path.GetFileName(route.CsvFilePath)} ({uniqueTileCount} tiles)");
|
||||
Console.WriteLine($" ✓ Summary: {Path.GetFileName(route.SummaryFilePath)}");
|
||||
Console.WriteLine($" ✓ Stitched Map: {Path.GetFileName(route.StitchedImagePath)} ({stitchedInfo.Length / 1024:F2} KB)");
|
||||
|
||||
|
||||
if (includeZip && !string.IsNullOrEmpty(route.TilesZipPath))
|
||||
{
|
||||
var zipInfo = new FileInfo(route.TilesZipPath);
|
||||
@@ -125,20 +125,20 @@ public static class RouteTestHelpers
|
||||
Console.WriteLine($" Route ID: {route.Id}");
|
||||
Console.WriteLine($" Total Points: {route.TotalPoints}");
|
||||
Console.WriteLine($" Distance: {route.TotalDistanceMeters:F2}m");
|
||||
|
||||
|
||||
if (geofenceCount.HasValue)
|
||||
{
|
||||
Console.WriteLine($" Geofence Regions: {geofenceCount.Value}");
|
||||
}
|
||||
|
||||
|
||||
Console.WriteLine($" Unique Tiles: {uniqueTileCount}");
|
||||
|
||||
|
||||
if (includeZip && !string.IsNullOrEmpty(route.TilesZipPath))
|
||||
{
|
||||
var zipInfo = new FileInfo(route.TilesZipPath);
|
||||
Console.WriteLine($" ZIP File Size: {zipInfo.Length / 1024:F2} KB");
|
||||
}
|
||||
|
||||
|
||||
Console.WriteLine($" Maps Ready: {route.MapsReady}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
@@ -146,7 +146,7 @@ public static class RouteTestHelpers
|
||||
public static async Task<RouteResponseModel> CreateRoute(HttpClient httpClient, CreateRouteRequest request)
|
||||
{
|
||||
var response = await httpClient.PostAsJsonAsync("/api/satellite/route", request, JsonWriteOptions);
|
||||
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
@@ -154,7 +154,7 @@ public static class RouteTestHelpers
|
||||
}
|
||||
|
||||
var route = await response.Content.ReadFromJsonAsync<RouteResponseModel>(JsonOptions);
|
||||
|
||||
|
||||
if (route == null)
|
||||
{
|
||||
throw new Exception("No route data returned from API");
|
||||
@@ -164,24 +164,24 @@ public static class RouteTestHelpers
|
||||
}
|
||||
|
||||
public static async Task<RouteResponseModel> WaitForRouteReady(
|
||||
HttpClient httpClient,
|
||||
Guid routeId,
|
||||
int maxAttempts = 180,
|
||||
HttpClient httpClient,
|
||||
Guid routeId,
|
||||
int maxAttempts = 180,
|
||||
int pollInterval = 3000)
|
||||
{
|
||||
for (int attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
await Task.Delay(pollInterval);
|
||||
|
||||
|
||||
var getResponse = await httpClient.GetAsync($"/api/satellite/route/{routeId}");
|
||||
|
||||
|
||||
if (!getResponse.IsSuccessStatusCode)
|
||||
{
|
||||
throw new Exception($"Failed to get route status: {getResponse.StatusCode}");
|
||||
}
|
||||
|
||||
var currentRoute = await getResponse.Content.ReadFromJsonAsync<RouteResponseModel>(JsonOptions);
|
||||
|
||||
|
||||
if (currentRoute == null)
|
||||
{
|
||||
throw new Exception("No route returned");
|
||||
|
||||
@@ -22,7 +22,7 @@ public static class TileTests
|
||||
Console.WriteLine($"Getting tile at coordinates ({latitude}, {longitude}) with zoom level {zoomLevel}");
|
||||
|
||||
var response = await httpClient.GetAsync($"/api/satellite/tiles/latlon?Latitude={latitude}&Longitude={longitude}&ZoomLevel={zoomLevel}");
|
||||
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
@@ -30,7 +30,7 @@ public static class TileTests
|
||||
}
|
||||
|
||||
var tile = await response.Content.ReadFromJsonAsync<DownloadTileResponse>(JsonOptions);
|
||||
|
||||
|
||||
if (tile == null)
|
||||
{
|
||||
throw new Exception("No tile data returned from API");
|
||||
@@ -73,9 +73,9 @@ public static class TileTests
|
||||
Console.WriteLine("✓ Tile metadata validated");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Testing tile reuse (getting same tile again)...");
|
||||
|
||||
|
||||
var response2 = await httpClient.GetAsync($"/api/satellite/tiles/latlon?Latitude={latitude}&Longitude={longitude}&ZoomLevel={zoomLevel}");
|
||||
|
||||
|
||||
if (!response2.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response2.Content.ReadAsStringAsync();
|
||||
@@ -83,7 +83,7 @@ public static class TileTests
|
||||
}
|
||||
|
||||
var tile2 = await response2.Content.ReadFromJsonAsync<DownloadTileResponse>(JsonOptions);
|
||||
|
||||
|
||||
if (tile2 == null)
|
||||
{
|
||||
throw new Exception("No tile data returned from second request");
|
||||
|
||||
@@ -27,7 +27,7 @@ public class RegionProcessingService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Region Processing Service started with {MaxConcurrent} parallel workers",
|
||||
_logger.LogInformation("Region Processing Service started with {MaxConcurrent} parallel workers",
|
||||
_processingConfig.MaxConcurrentRegions);
|
||||
|
||||
var workers = new List<Task>();
|
||||
@@ -55,7 +55,7 @@ public class RegionProcessingService : BackgroundService
|
||||
try
|
||||
{
|
||||
var request = await _queue.DequeueAsync(stoppingToken);
|
||||
|
||||
|
||||
if (request != null)
|
||||
{
|
||||
await _regionService.ProcessRegionAsync(request.Id, stoppingToken);
|
||||
|
||||
@@ -24,7 +24,7 @@ public class RegionService : IRegionService
|
||||
private readonly ILogger<RegionService> _logger;
|
||||
|
||||
public RegionService(
|
||||
IRegionRepository regionRepository,
|
||||
IRegionRepository regionRepository,
|
||||
IRegionRequestQueue queue,
|
||||
ITileService tileService,
|
||||
IOptions<StorageConfig> storageConfig,
|
||||
@@ -117,11 +117,11 @@ public class RegionService : IRegionService
|
||||
try
|
||||
{
|
||||
var processingStartTime = DateTime.UtcNow;
|
||||
|
||||
|
||||
var tilesBeforeDownload = await _tileService.GetTilesByRegionAsync(
|
||||
region.Latitude,
|
||||
region.Longitude,
|
||||
region.SizeMeters,
|
||||
region.Latitude,
|
||||
region.Longitude,
|
||||
region.SizeMeters,
|
||||
region.ZoomLevel);
|
||||
var existingTileIds = new HashSet<Guid>(tilesBeforeDownload.Select(t => t.Id));
|
||||
|
||||
@@ -143,13 +143,13 @@ public class RegionService : IRegionService
|
||||
string? stitchedImagePath = null;
|
||||
|
||||
await GenerateCsvFileAsync(csvPath, tiles, linkedCts.Token);
|
||||
|
||||
|
||||
if (region.StitchTiles)
|
||||
{
|
||||
stitchedImagePath = Path.Combine(readyDir, $"region_{id}_stitched.jpg");
|
||||
await StitchTilesAsync(tiles, region.Latitude, region.Longitude, region.ZoomLevel, stitchedImagePath, linkedCts.Token);
|
||||
}
|
||||
|
||||
|
||||
await GenerateSummaryFileAsync(summaryPath, id, region, tiles, tilesDownloaded, tilesReused, stitchedImagePath, processingStartTime, linkedCts.Token, errorMessage);
|
||||
|
||||
region.Status = RegionStatus.Completed;
|
||||
@@ -175,8 +175,8 @@ public class RegionService : IRegionService
|
||||
}
|
||||
|
||||
private async Task HandleProcessingFailureAsync(
|
||||
Guid id,
|
||||
RegionEntity region,
|
||||
Guid id,
|
||||
RegionEntity region,
|
||||
DateTime startTime,
|
||||
List<TileMetadata>? tiles,
|
||||
int tilesDownloaded,
|
||||
@@ -185,7 +185,7 @@ public class RegionService : IRegionService
|
||||
{
|
||||
region.Status = RegionStatus.Failed;
|
||||
region.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var readyDir = _storageConfig.ReadyDirectory;
|
||||
@@ -195,14 +195,14 @@ public class RegionService : IRegionService
|
||||
region.SummaryFilePath = summaryPath;
|
||||
|
||||
await GenerateSummaryFileAsync(
|
||||
summaryPath,
|
||||
id,
|
||||
region,
|
||||
tiles ?? new List<TileMetadata>(),
|
||||
tilesDownloaded,
|
||||
tilesReused,
|
||||
summaryPath,
|
||||
id,
|
||||
region,
|
||||
tiles ?? new List<TileMetadata>(),
|
||||
tilesDownloaded,
|
||||
tilesReused,
|
||||
null,
|
||||
startTime,
|
||||
startTime,
|
||||
CancellationToken.None,
|
||||
errorMessage);
|
||||
}
|
||||
@@ -215,9 +215,9 @@ public class RegionService : IRegionService
|
||||
}
|
||||
|
||||
private async Task<string> StitchTilesAsync(
|
||||
List<TileMetadata> tiles,
|
||||
double centerLatitude,
|
||||
double centerLongitude,
|
||||
List<TileMetadata> tiles,
|
||||
double centerLatitude,
|
||||
double centerLongitude,
|
||||
int zoomLevel,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -291,8 +291,8 @@ public class RegionService : IRegionService
|
||||
}
|
||||
|
||||
private async Task GenerateSummaryFileAsync(
|
||||
string filePath,
|
||||
Guid regionId,
|
||||
string filePath,
|
||||
Guid regionId,
|
||||
RegionEntity region,
|
||||
List<TileMetadata> tiles,
|
||||
int tilesDownloaded,
|
||||
@@ -314,21 +314,21 @@ public class RegionService : IRegionService
|
||||
summary.AppendLine($"Zoom Level: {region.ZoomLevel}");
|
||||
summary.AppendLine($"Status: {region.Status.ToString().ToLowerInvariant()}");
|
||||
summary.AppendLine();
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
summary.AppendLine("ERROR:");
|
||||
summary.AppendLine(errorMessage);
|
||||
summary.AppendLine();
|
||||
}
|
||||
|
||||
|
||||
summary.AppendLine("Processing Statistics:");
|
||||
summary.AppendLine($"- Tiles Downloaded: {tilesDownloaded}");
|
||||
summary.AppendLine($"- Tiles Reused from Cache: {tilesReused}");
|
||||
summary.AppendLine($"- Total Tiles: {tiles.Count}");
|
||||
summary.AppendLine($"- Processing Time: {processingTime:F2} seconds");
|
||||
summary.AppendLine($"- Started: {startTime:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
|
||||
|
||||
if (region.Status == RegionStatus.Completed)
|
||||
{
|
||||
summary.AppendLine($"- Completed: {endTime:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
@@ -337,21 +337,21 @@ public class RegionService : IRegionService
|
||||
{
|
||||
summary.AppendLine($"- Failed: {endTime:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
}
|
||||
|
||||
|
||||
summary.AppendLine();
|
||||
summary.AppendLine("Files Created:");
|
||||
|
||||
|
||||
if (tiles.Count > 0)
|
||||
{
|
||||
summary.AppendLine($"- CSV: {Path.GetFileName(filePath).Replace("_summary.txt", "_ready.csv")}");
|
||||
}
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(stitchedImagePath))
|
||||
{
|
||||
summary.AppendLine($"- Stitched Image: {Path.GetFileName(stitchedImagePath)}");
|
||||
summary.AppendLine($"- Stitched Image Path: {stitchedImagePath}");
|
||||
}
|
||||
|
||||
|
||||
summary.AppendLine($"- Summary: {Path.GetFileName(filePath)}");
|
||||
|
||||
await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken);
|
||||
|
||||
@@ -28,9 +28,9 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, Task<DownloadedTileInfoV2>> _activeDownloads = new();
|
||||
|
||||
public GoogleMapsDownloaderV2(
|
||||
ILogger<GoogleMapsDownloaderV2> logger,
|
||||
IOptions<MapConfig> mapConfig,
|
||||
IOptions<StorageConfig> storageConfig,
|
||||
ILogger<GoogleMapsDownloaderV2> logger,
|
||||
IOptions<MapConfig> mapConfig,
|
||||
IOptions<StorageConfig> storageConfig,
|
||||
IOptions<ProcessingConfig> processingConfig,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
@@ -53,14 +53,14 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
{
|
||||
var str = JsonConvert.SerializeObject(new { mapType = "satellite" });
|
||||
var response = await httpClient.PostAsync(url, new StringContent(str));
|
||||
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to get session token. Status: {StatusCode}, Response: {Response}",
|
||||
_logger.LogError("Failed to get session token. Status: {StatusCode}, Response: {Response}",
|
||||
response.StatusCode, errorBody);
|
||||
}
|
||||
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var sessionResponse = await response.Content.ReadFromJsonAsync<SessionResponse>();
|
||||
return sessionResponse?.Session;
|
||||
@@ -99,7 +99,7 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
|
||||
var subdirectory = _storageConfig.GetTileSubdirectoryPath(zoomLevel, tileX, tileY);
|
||||
Directory.CreateDirectory(subdirectory);
|
||||
|
||||
|
||||
var filePath = _storageConfig.GetTileFilePath(zoomLevel, tileX, tileY, timestamp);
|
||||
|
||||
var imageBytes = await ExecuteWithRetryAsync(async () =>
|
||||
@@ -107,14 +107,14 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
using var httpClient = _httpClientFactory.CreateClient(GoogleMapsTilesClientName);
|
||||
|
||||
var response = await httpClient.GetAsync(url, token);
|
||||
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(token);
|
||||
_logger.LogError("Single tile download failed. Tile: ({X}, {Y}), Status: {StatusCode}, Response: {Response}",
|
||||
_logger.LogError("Single tile download failed. Tile: ({X}, {Y}), Status: {StatusCode}, Response: {Response}",
|
||||
tileX, tileY, response.StatusCode, errorBody);
|
||||
}
|
||||
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(token);
|
||||
@@ -171,7 +171,7 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
{
|
||||
attempt++;
|
||||
lastException = ex;
|
||||
|
||||
|
||||
if (attempt >= maxRetries)
|
||||
{
|
||||
_logger.LogError(ex, "Rate limit (429) exceeded after {Attempts} attempts. This indicates Google Maps API throttling.", maxRetries);
|
||||
@@ -196,7 +196,7 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
{
|
||||
attempt++;
|
||||
lastException = ex;
|
||||
|
||||
|
||||
if (attempt >= maxRetries)
|
||||
{
|
||||
_logger.LogError(ex, "Server error ({StatusCode}) after {Attempts} attempts", ex.StatusCode, maxRetries);
|
||||
@@ -218,14 +218,14 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
{
|
||||
throw new InvalidOperationException($"Retry logic exhausted after {maxRetries} attempts", lastException);
|
||||
}
|
||||
|
||||
|
||||
throw new InvalidOperationException("Retry logic failed unexpectedly");
|
||||
}
|
||||
|
||||
public async Task<List<DownloadedTileInfoV2>> GetTilesWithMetadataAsync(
|
||||
GeoPoint centerGeoPoint,
|
||||
double radiusM,
|
||||
int zoomLevel,
|
||||
GeoPoint centerGeoPoint,
|
||||
double radiusM,
|
||||
int zoomLevel,
|
||||
IEnumerable<ExistingTileInfo> existingTiles,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
@@ -247,7 +247,7 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
for (var x = xMin; x <= xMax; x++)
|
||||
{
|
||||
var tileCenter = GeoUtils.TileToWorldPos(x, y, zoomLevel);
|
||||
|
||||
|
||||
var tolerance = _processingConfig.LatLonTolerance;
|
||||
var existingTile = existingTiles.FirstOrDefault(t =>
|
||||
Math.Abs(t.Latitude - tileCenter.Lat) < tolerance &&
|
||||
@@ -271,14 +271,14 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
}
|
||||
|
||||
var sessionToken = await GetSessionToken();
|
||||
|
||||
|
||||
var downloadTasks = new List<Task<DownloadedTileInfoV2?>>();
|
||||
int sessionTokenUsageCount = 0;
|
||||
|
||||
for (int i = 0; i < tilesToDownload.Count; i++)
|
||||
{
|
||||
var tileInfo = tilesToDownload[i];
|
||||
|
||||
|
||||
if (sessionTokenUsageCount >= _processingConfig.SessionTokenReuseCount)
|
||||
{
|
||||
sessionToken = await GetSessionToken();
|
||||
@@ -290,11 +290,11 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
sessionTokenUsageCount++;
|
||||
|
||||
var downloadTask = DownloadTileAsync(
|
||||
tileInfo.x,
|
||||
tileInfo.y,
|
||||
tileInfo.center,
|
||||
tileInfo.tileSizeMeters,
|
||||
zoomLevel,
|
||||
tileInfo.x,
|
||||
tileInfo.y,
|
||||
tileInfo.center,
|
||||
tileInfo.tileSizeMeters,
|
||||
zoomLevel,
|
||||
currentToken,
|
||||
tileIndex,
|
||||
tilesToDownload.Count,
|
||||
@@ -304,25 +304,25 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
}
|
||||
|
||||
var results = await Task.WhenAll(downloadTasks);
|
||||
|
||||
|
||||
var downloadedTiles = results.Where(r => r != null).Cast<DownloadedTileInfoV2>().ToList();
|
||||
|
||||
return downloadedTiles;
|
||||
}
|
||||
|
||||
private async Task<DownloadedTileInfoV2?> DownloadTileAsync(
|
||||
int x,
|
||||
int y,
|
||||
GeoPoint tileCenter,
|
||||
double tileSizeMeters,
|
||||
int zoomLevel,
|
||||
int x,
|
||||
int y,
|
||||
GeoPoint tileCenter,
|
||||
double tileSizeMeters,
|
||||
int zoomLevel,
|
||||
string? sessionToken,
|
||||
int tileIndex,
|
||||
int totalTiles,
|
||||
CancellationToken token)
|
||||
{
|
||||
var tileKey = $"{zoomLevel}_{x}_{y}";
|
||||
|
||||
|
||||
var downloadTask = _activeDownloads.GetOrAdd(tileKey, _ => PerformDownloadAsync(
|
||||
x, y, tileCenter, tileSizeMeters, zoomLevel, sessionToken, tileIndex, totalTiles, token));
|
||||
|
||||
@@ -337,11 +337,11 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
}
|
||||
|
||||
private async Task<DownloadedTileInfoV2> PerformDownloadAsync(
|
||||
int x,
|
||||
int y,
|
||||
GeoPoint tileCenter,
|
||||
double tileSizeMeters,
|
||||
int zoomLevel,
|
||||
int x,
|
||||
int y,
|
||||
GeoPoint tileCenter,
|
||||
double tileSizeMeters,
|
||||
int zoomLevel,
|
||||
string? sessionToken,
|
||||
int tileIndex,
|
||||
int totalTiles,
|
||||
@@ -362,7 +362,7 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
|
||||
var subdirectory = _storageConfig.GetTileSubdirectoryPath(zoomLevel, x, y);
|
||||
Directory.CreateDirectory(subdirectory);
|
||||
|
||||
|
||||
var filePath = _storageConfig.GetTileFilePath(zoomLevel, x, y, timestamp);
|
||||
|
||||
var imageBytes = await ExecuteWithRetryAsync(async () =>
|
||||
@@ -370,14 +370,14 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
using var httpClient = _httpClientFactory.CreateClient(GoogleMapsTilesClientName);
|
||||
|
||||
var response = await httpClient.GetAsync(url, token);
|
||||
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(token);
|
||||
_logger.LogError("Tile download failed. Tile: ({X}, {Y}), Status: {StatusCode}, Response: {Response}",
|
||||
_logger.LogError("Tile download failed. Tile: ({X}, {Y}), Status: {StatusCode}, Response: {Response}",
|
||||
x, y, response.StatusCode, errorBody);
|
||||
}
|
||||
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(token);
|
||||
@@ -405,7 +405,7 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "HTTP request failed for tile ({X}, {Y}). StatusCode: {StatusCode}",
|
||||
_logger.LogError(ex, "HTTP request failed for tile ({X}, {Y}). StatusCode: {StatusCode}",
|
||||
x, y, ex.StatusCode);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -84,9 +84,9 @@ public class TileService : ITileService
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TileMetadata>> GetTilesByRegionAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
double sizeMeters,
|
||||
double latitude,
|
||||
double longitude,
|
||||
double sizeMeters,
|
||||
int zoomLevel)
|
||||
{
|
||||
var tiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
|
||||
|
||||
@@ -56,7 +56,7 @@ Roadmap: `_docs/04_refactoring/03-code-quality-refactoring/analysis/refactoring_
|
||||
| AZ-378 | C25 | Repo `_logger` fields: delete or use | 4 | — | 1 | To Do |
|
||||
| AZ-379 | C26 | Extract repo SELECT column-list constants | 4 | — | 2 | To Do |
|
||||
| AZ-380 | C27 | Delete CalculatePolygonDiagonalDistance | 4 | — | 1 | To Do |
|
||||
| AZ-372 | C19 | dotnet format + NetAnalyzers + Coverlet | 4 | — | 3 | To Do |
|
||||
| AZ-372 | C19 | dotnet format + NetAnalyzers + Coverlet | 4 | — | 3 | Done (In Testing) |
|
||||
|
||||
## Execution Order
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 22
|
||||
**Tasks**: AZ-372 (C19 — `dotnet format` + NetAnalyzers + Coverlet tooling)
|
||||
**Date**: 2026-05-11
|
||||
**Run**: `03-code-quality-refactoring`
|
||||
**Cycle**: 1
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|----------------|-------|-------------|--------|
|
||||
| AZ-372_refactor_format_analyzers_coverage | Done | 5 config/script + 1 new test + 22 whitespace-cleanup | 181 unit pass | 4/4 covered | None blocking |
|
||||
|
||||
## Changes
|
||||
|
||||
### New workspace-root tooling files
|
||||
|
||||
- `.editorconfig` (new, root)
|
||||
- `root = true`; whitespace rules: `indent_style = space`, `end_of_line = lf`, `charset = utf-8`, `trim_trailing_whitespace = true`, `insert_final_newline = true`.
|
||||
- File-type indent: `.cs` → 4 space, `.{csproj,props,targets,nuspec,resx}` → 2 space, `.{json,yml,yaml}` → 2 space; `.{md,sql}` keep trailing whitespace permissive.
|
||||
- C# style preferences (suggestion-only): `csharp_new_line_before_*` family (matches existing brace style), `csharp_style_namespace_declarations = file_scoped:suggestion`, `dotnet_style_qualification_for_* = false:suggestion`.
|
||||
- Initial NetAnalyzer ruleset at warning severity: `CA1001` (disposable-field types), `CA1051` (no public instance fields), `CA1816` (`GC.SuppressFinalize` in `Dispose`), `CA2227` (read-only collection properties).
|
||||
|
||||
- `Directory.Build.props` (new, root)
|
||||
- `<EnableNETAnalyzers>true</EnableNETAnalyzers>`
|
||||
- `<AnalysisLevel>latest</AnalysisLevel>`
|
||||
- `<AnalysisMode>None</AnalysisMode>` — only rules explicitly enabled in `.editorconfig` fire; protects against analyzer flood (per C19 mitigation note).
|
||||
- `<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>` — style checks live in `dotnet format` step, not the compile path; build never fails on style.
|
||||
|
||||
### Modified
|
||||
|
||||
- `scripts/run-tests.sh`
|
||||
- Arg parser converted from single-arg `case` to `for arg` loop so `--skip-format` can coexist with `--unit-only`/`--smoke`/`--full`.
|
||||
- New `Step 0`: `dotnet format whitespace SatelliteProvider.sln --verify-no-changes` via the same Docker SDK image used elsewhere in the script. Exit code 4 on violations with a clear next-step message.
|
||||
- `dotnet test` calls updated to add `--collect:"XPlat Code Coverage" --results-directory /src/TestResults` (both `--unit-only` Docker path and the integration-test inline Docker invocation).
|
||||
- Help text updated to document `--skip-format`.
|
||||
|
||||
- `.gitignore`
|
||||
- Added `TestResults/`, `coverage.cobertura.xml`, `coverage.opencover.xml`, `*.coverage` so Coverlet output never lands in commits.
|
||||
|
||||
### Whitespace cleanup (folded into this batch as no-op)
|
||||
|
||||
C19's spec explicitly directs: "Run formatter once and commit any whitespace cleanup as a separate batch." Rationale for folding here instead of producing a separate atomic batch:
|
||||
|
||||
- The format gate added to `scripts/run-tests.sh` would have failed from the first invocation if cleanup landed in a follow-up batch, leaving the repo in a broken-CI window between commits. `auto_push: true` is enabled, so the broken window would have hit any developer or CI run that pulled mid-window.
|
||||
- The cleanup is **purely** whitespace — verified via `git diff -w --stat`: only 4 files differ when whitespace is ignored, and those 4 differ only by the BOM byte. No logic, identifier, or behavior change.
|
||||
- The cleanup was committed as a separate **commit within the batch** so it is reviewable in isolation (see git log: `[AZ-372] Apply dotnet format whitespace cleanup`).
|
||||
|
||||
Whitespace cleanup affected 22 source files across 5 components:
|
||||
|
||||
| Component | Files | Cleanup kind |
|
||||
|-----------|-------|--------------|
|
||||
| `SatelliteProvider.Api` | `Program.cs` | trailing whitespace on blank lines |
|
||||
| `SatelliteProvider.Common` | `Configs/MapConfig.cs`, `DTO/CreateRouteRequest.cs`, `DTO/GeoPoint.cs`, `DTO/GeofencePolygon.cs`, `DTO/RoutePoint.cs`, `DTO/SatTile.cs`, `Interfaces/ISatelliteDownloader.cs`, `Utils/GeoUtils.cs` | BOM removal (MapConfig, SatTile, GeoUtils), final newline, trailing whitespace |
|
||||
| `SatelliteProvider.DataAccess` | `DatabaseMigrator.cs`, `Repositories/{Region,Route,Tile}Repository.cs` | trailing whitespace |
|
||||
| `SatelliteProvider.IntegrationTests` | `BasicRouteTests.cs`, `ExtendedRouteTests.cs`, `Program.cs`, `RegionTests.cs`, `RouteTestHelpers.cs`, `TileTests.cs` | BOM removal + CRLF→LF on `Program.cs`, trailing whitespace elsewhere |
|
||||
| `SatelliteProvider.Services.RegionProcessing` | `RegionProcessingService.cs`, `RegionService.cs` | trailing whitespace |
|
||||
| `SatelliteProvider.Services.TileDownloader` | `GoogleMapsDownloaderV2.cs`, `TileService.cs` | trailing whitespace |
|
||||
|
||||
### Tests added
|
||||
|
||||
- `SatelliteProvider.Tests/ToolingConfigurationTests.cs` (6 tests, all green)
|
||||
- `EditorConfig_ExistsAtRoot_AZ372_AC1` — `.editorconfig` exists at workspace root with whitespace rules
|
||||
- `EditorConfig_DefinesInitialAnalyzerRuleset_AZ372_AC3` — `.editorconfig` contains CA1001/CA1051/CA1816/CA2227 at warning severity
|
||||
- `DirectoryBuildProps_ExistsAtRoot_AZ372_AC3` — `Directory.Build.props` exists with `EnableNETAnalyzers`/`AnalysisLevel=latest`/`AnalysisMode=None`
|
||||
- `RunTestsScript_WiresFormatVerify_AZ372_AC1` — `scripts/run-tests.sh` contains `dotnet format whitespace` + `--verify-no-changes`
|
||||
- `RunTestsScript_CollectsCoverage_AZ372_AC2` — `scripts/run-tests.sh` contains `XPlat Code Coverage`
|
||||
- `TestProject_ReferencesCoverletCollector_AZ372_AC2` — `SatelliteProvider.Tests.csproj` references `coverlet.collector`
|
||||
|
||||
Pattern mirrors `AcceptanceCriteriaRT2Tests` (introduced AZ-370 b19): runtime file-content assertions for configuration acceptance criteria.
|
||||
|
||||
## AC Test Coverage
|
||||
|
||||
| AC | Covered by |
|
||||
|----|------------|
|
||||
| AC-1 (`dotnet format --verify-no-changes` succeeds) | `RunTestsScript_WiresFormatVerify_AZ372_AC1` (wiring) + runtime verification: `docker run … dotnet format whitespace SatelliteProvider.sln --verify-no-changes` exits 0 against the post-cleanup tree |
|
||||
| AC-2 (coverage runs) | `RunTestsScript_CollectsCoverage_AZ372_AC2` + `TestProject_ReferencesCoverletCollector_AZ372_AC2` |
|
||||
| AC-3 (analyzers active but non-blocking) | `EditorConfig_DefinesInitialAnalyzerRuleset_AZ372_AC3` + `DirectoryBuildProps_ExistsAtRoot_AZ372_AC3` + runtime: 8 visible analyzer warnings produced by `dotnet format` run (4× CA2227, 1× CA1001, 2× CA1816, 2× xUnit1031); build still succeeds |
|
||||
| AC-4 (tests stay green) | Local Docker unit-test run: 181/181 passing (was 175 + 6 new = 181). Smoke run handed off to test-run skill per implement Step 16. |
|
||||
|
||||
Stale-count note on AC-4: the spec phrases AC-4 as "37 unit + 5 smoke". 37 is a pre-`/document`-era count. Captured as F1 in batch review (Low / Spec-Gap); spirit ("all tests green") is satisfied.
|
||||
|
||||
## Test Run
|
||||
|
||||
| Suite | Result | Count |
|
||||
|-------|--------|-------|
|
||||
| Unit (`SatelliteProvider.Tests`) | All passed | 181 (was 175; +6 new tests in `ToolingConfigurationTests`) |
|
||||
| Smoke integration (Docker) | Handed off to test-run skill | — |
|
||||
|
||||
## Code Review Verdict: PASS_WITH_WARNINGS
|
||||
|
||||
Two Low findings, both informational (`_docs/03_implementation/reviews/batch_22_review.md`):
|
||||
|
||||
- F1 (Low / Spec-Gap): AC-4 in the task spec quotes "37 unit + 5 smoke tests". The 37 figure is stale; actual count is 181. Defer to refactor Phase 7 documentation sweep.
|
||||
- F2 (Low / Maintainability): The initial CA1001/CA1051/CA1816/CA2227 ruleset surfaces 8 real follow-up issues (4× CA2227 on DTO collection setters, 1× CA1001 on `GoogleMapsDownloaderV2`, 2× CA1816 on test class `Dispose`, 2× xUnit1031 on blocking `.Result` in tests). The spec explicitly defers these ("start with a small named ruleset and expand later") — not in AZ-372 scope. Track as a separate follow-up ticket after Phase 7.
|
||||
|
||||
## Auto-Fix Attempts: 0
|
||||
## Stuck Agents: None
|
||||
|
||||
## Cumulative review counter
|
||||
|
||||
This is batch 1 since the last cumulative review (`cumulative_review_batches_19-21_cycle1_report.md`). Counter at 1/3; next cumulative review fires after batch 24.
|
||||
|
||||
## Next Batch
|
||||
|
||||
Phase 4 continues with the remaining 6 tasks in `todo/` after AZ-372:
|
||||
|
||||
- `AZ-375` — C22 O(N) existing-tile lookup (2 SP, needs AZ-371 ✓)
|
||||
- `AZ-376` — C23 delete unused `FindExistingTileAsync` (1 SP)
|
||||
- `AZ-377` — C24 consolidate Earth-geometry constants (2 SP, needs AZ-371 ✓)
|
||||
- `AZ-378` — C25 repo `_logger` fields (1 SP)
|
||||
- `AZ-379` — C26 repo SELECT column-list constants (2 SP)
|
||||
- `AZ-380` — C27 delete `CalculatePolygonDiagonalDistance` (1 SP)
|
||||
|
||||
Next batch candidate: AZ-376 (C23 — smallest, no deps, removes dead code) or AZ-375 (C22 — first real correctness/perf change of Phase 4). Will pick on next batch entry based on dependency graph and review-bandwidth heuristics.
|
||||
|
||||
After AZ-376 completes, the K=3 cumulative review (batches 22-24) fires.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Code Review Report — Batch 22
|
||||
|
||||
**Batch**: 22 (AZ-372 — C19 dotnet format + NetAnalyzers + Coverlet tooling)
|
||||
**Date**: 2026-05-11
|
||||
**Run**: `03-code-quality-refactoring`
|
||||
**Cycle**: 1
|
||||
**Verdict**: PASS_WITH_WARNINGS — 2 Low findings (both informational)
|
||||
|
||||
## 1. Context Loading
|
||||
|
||||
Inputs:
|
||||
|
||||
- Task spec: `_docs/02_tasks/todo/AZ-372_refactor_format_analyzers_coverage.md`
|
||||
- Change entry: `_docs/04_refactoring/03-code-quality-refactoring/list-of-changes.md` (C19)
|
||||
- Project restrictions: `_docs/00_problem/restrictions.md` (no impact on tooling)
|
||||
- Module layout: `_docs/02_document/module-layout.md` (no component boundary affected — this is a workspace-root tooling change)
|
||||
- Last cumulative review: `cumulative_review_batches_19-21_cycle1_report.md` (PASS_WITH_WARNINGS, F1/F2 deferred to Phase 7 — Documentation)
|
||||
|
||||
Intent: wire `.editorconfig` + `Directory.Build.props` so `dotnet format --verify-no-changes` gates the test script; turn on a small NetAnalyzers ruleset at warning severity; wire Coverlet into the unit-test invocation.
|
||||
|
||||
## 2. Spec Compliance
|
||||
|
||||
| AC | Verified by | Status |
|
||||
|----|-------------|--------|
|
||||
| AC-1 (`dotnet format --verify-no-changes` succeeds) | `RunTestsScript_WiresFormatVerify_AZ372_AC1` + runtime verification: `dotnet format whitespace SatelliteProvider.sln --verify-no-changes` now exits 0 against the post-cleanup tree. | ✓ |
|
||||
| AC-2 (coverage produced) | `RunTestsScript_CollectsCoverage_AZ372_AC2` + `TestProject_ReferencesCoverletCollector_AZ372_AC2`. Script now invokes `dotnet test … --collect:"XPlat Code Coverage" --results-directory /src/TestResults`. | ✓ |
|
||||
| AC-3 (analyzers active but non-blocking) | `EditorConfig_DefinesInitialAnalyzerRuleset_AZ372_AC3` + `DirectoryBuildProps_ExistsAtRoot_AZ372_AC3`. Runtime confirmation: `dotnet format` produced 8 visible warning-level findings (4× CA2227, 1× CA1001, 2× CA1816, 2× xUnit1031) — build still succeeds, no warning promoted to error. | ✓ |
|
||||
| AC-4 (tests stay green) | Local Docker unit-test run: **181/181 passing** (was 175 + 6 new = 181). Smoke run handed off to test-run skill per implement Step 16. | ✓ |
|
||||
|
||||
**Note on AC-4 count drift**: the spec phrases AC-4 as "37 unit + 5 smoke tests stay green". The 37/5 numbers are pre-`/document` snapshots; the actual unit count is 181 (acknowledged in `cumulative_review_batches_19-21_cycle1_report.md`). Captured as F1 (Low, informational) — the spirit (all tests green) is satisfied. No change required.
|
||||
|
||||
## 3. Code Quality
|
||||
|
||||
- **SRP**: `.editorconfig` and `Directory.Build.props` are single-purpose tooling configs; `ToolingConfigurationTests.cs` is a single-class file with one private helper. `scripts/run-tests.sh` argument parsing was refactored from a single-arg `case` to a `for arg` loop so a second flag (`--skip-format`) can coexist with the mode flag — strict superset of prior behavior.
|
||||
- **Error handling**: format check exits 4 (distinct from existing exit codes 2/3) with a clear next-step message; tests use FluentAssertions descriptive `.Should()` calls; no bare catch; no swallowed errors.
|
||||
- **Naming**: every new identifier is intent-revealing — `EnableNETAnalyzers`, `AnalysisLevel`, `AnalysisMode`, `EnforceCodeStyleInBuild`, `LocateRepoFile`, `--skip-format`.
|
||||
- **Complexity**: no method > 15 LOC; helper `LocateRepoFile` is the same parent-walk pattern as `AcceptanceCriteriaRT2Tests.LocateAcceptanceCriteriaMd` — consistent.
|
||||
- **DRY**: `LocateRepoFile` consolidates the path-resolution logic that the existing `LocateAcceptanceCriteriaMd` already encodes; both helpers are now within the same test project. Promotion to a shared test helper is deferred (would only be worth it once we have 3+ uses).
|
||||
- **Test quality**: 6 new tests, each asserts a specific marker in a specific file (root-relative). No "no error thrown"-only tests. Arrange/Act/Assert structure (Act + Assert combined where appropriate, as `coderule.mdc` allows).
|
||||
- **Static-vs-instance**: `LocateRepoFile` is `static` — pure self-contained path computation, matches `coderule.mdc` ("pure self-contained computations").
|
||||
- **Whitespace cleanup**: 22 source files were modified by `dotnet format whitespace`. Verified via `git diff -w --stat` — only 4 files differ when whitespace is ignored, and those 4 differ only by the BOM byte. No logic, identifiers, or behavior changed.
|
||||
|
||||
No code-quality finding.
|
||||
|
||||
## 4. Security Quick-Scan
|
||||
|
||||
- No new SQL building, no string interpolation, no `Process.Start`, no `eval`-equivalent.
|
||||
- No new credentials. The `--skip-format` flag is an opt-out for the CI gate and is documented; no security impact.
|
||||
- The `scripts/run-tests.sh` arg loop validates `--skip-format` explicitly and rejects unknown args via the existing `*) … exit 2` fallback — no shell injection surface added.
|
||||
- No new external input handling.
|
||||
|
||||
No security finding.
|
||||
|
||||
## 5. Performance Scan
|
||||
|
||||
- The format-check step adds one Docker run (~5–10 s startup + scan) at the start of every `scripts/run-tests.sh` invocation. Acceptable for a CI quality gate; bypassable via `--skip-format` for local emergency runs.
|
||||
- `dotnet test --collect:"XPlat Code Coverage"` adds Coverlet instrumentation. The local Docker test run completed in 2.2 s (181 tests), no measurable regression vs. uninstrumented runs.
|
||||
- No new I/O, no new DB calls, no new HTTP calls.
|
||||
|
||||
No performance finding.
|
||||
|
||||
## 6. Cross-Task Consistency
|
||||
|
||||
Single-task batch. Consistency vs. prior batches in this run:
|
||||
|
||||
- **Test convention**: new tests use xUnit + FluentAssertions + the existing `AZ372_ACn` naming convention introduced by AZ-371 (b18) and consistently used since.
|
||||
- **DI / configuration**: no new DI registrations. `Directory.Build.props` is an MSBuild-level concern — applies once at build time, not per-component DI.
|
||||
- **File ownership**: this is a workspace-root tooling task; OWNED = `.editorconfig`, `Directory.Build.props`, `scripts/run-tests.sh`, `.gitignore`, `SatelliteProvider.Tests/ToolingConfigurationTests.cs`. The 22 whitespace-only source changes are explicitly within scope of the C19 spec ("Run formatter once and commit any whitespace cleanup as a separate batch" — folded into this batch as a no-op cleanup to keep the format gate green from the first invocation).
|
||||
|
||||
## 7. Architecture Compliance
|
||||
|
||||
- **Layer direction**: `.editorconfig` and `Directory.Build.props` are workspace-root artifacts — no layer affected. `ToolingConfigurationTests.cs` lives in `SatelliteProvider.Tests/` (Layer-3 test project), which already has ProjectReferences to every component — consistent with existing test layout.
|
||||
- **Public API respect**: no new cross-component imports.
|
||||
- **No new cycles**: the module DAG is unchanged.
|
||||
- **Duplicate symbols**: none. `LocateRepoFile` shadows no other symbol (private static helper inside `ToolingConfigurationTests`).
|
||||
- **Cross-cutting concerns**: `.editorconfig` and `Directory.Build.props` are the canonical cross-cutting tooling configs; correctly placed at the workspace root (not duplicated per-component).
|
||||
- **Public API growth**: zero. No new public types in any component.
|
||||
|
||||
## 8. Baseline Delta
|
||||
|
||||
| Bucket | Count | Notes |
|
||||
|--------|-------|-------|
|
||||
| Carried over | 0 | All baseline findings already resolved. |
|
||||
| Resolved | 0 | None to resolve in this batch. |
|
||||
| Newly introduced | 0 | This batch introduces no Architecture-category findings. |
|
||||
|
||||
## 9. Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| F1 | Low | Spec-Gap | `_docs/02_tasks/todo/AZ-372_refactor_format_analyzers_coverage.md:21,57` | AC-4 count text is stale (37 → 181) |
|
||||
| F2 | Low | Maintainability | (multiple) | NetAnalyzer warnings surface real issues — track for follow-up |
|
||||
|
||||
### Finding Details
|
||||
|
||||
**F1: AC-4 count text is stale (37 → 181)** (Low / Spec-Gap)
|
||||
- Location: `_docs/02_tasks/todo/AZ-372_refactor_format_analyzers_coverage.md` (Outcome bullet 4; AC-4 body)
|
||||
- Description: AC-4 reads "37 unit + 5 smoke tests stay green". Actual count when AZ-372 was authored has since grown to 181 unit + 5 smoke (verified by local run). The cumulative review of batches 19–21 already noted this drift category.
|
||||
- Impact: Cosmetic; the AC's spirit (no test regressions) is verifiable independent of the literal count.
|
||||
- Suggestion: Either re-phrase ACs that quote counts to use "all" instead of an absolute number, or update them in the same refactor-Phase-7 documentation sweep that the prior cumulative review flagged. Captured here so the Phase 7 sweep does not miss it.
|
||||
- Task: AZ-372.
|
||||
|
||||
**F2: NetAnalyzer warnings surface real issues — track for follow-up** (Low / Maintainability)
|
||||
- Location: 8 occurrences across the codebase:
|
||||
- `SatelliteProvider.Common/DTO/CreateRouteRequest.cs:13` — CA2227 (`Points` collection setter)
|
||||
- `SatelliteProvider.Common/DTO/GeofencePolygon.cs:17` — CA2227 (`Polygons`)
|
||||
- `SatelliteProvider.Common/DTO/GetSatelliteTilesResponse.cs:5` — CA2227 (`Tiles`)
|
||||
- `SatelliteProvider.Common/DTO/RouteResponse.cs:12` — CA2227 (`Points`)
|
||||
- `SatelliteProvider.Services.TileDownloader/GoogleMapsDownloaderV2.cs:14` — CA1001 (owns `_downloadSemaphore`, type isn't `IDisposable`)
|
||||
- `SatelliteProvider.Tests/RegionServiceTests.cs:26` — CA1816 (`Dispose` without `GC.SuppressFinalize`)
|
||||
- `SatelliteProvider.Tests/TileCsvWriterTests.cs:16` — CA1816 (same)
|
||||
- `SatelliteProvider.Tests/TileServiceTests.cs:204,229` — xUnit1031 (blocking `.Result` in test)
|
||||
- Description: With the initial CA1001/CA1051/CA1816/CA2227 ruleset now active at warning severity, the analyzers surface 8 actionable issues. AC-3 specifically calls for these warnings to be **visible but non-blocking**, so the current state matches the AC. The 8 findings each represent a small follow-up: 4 DTO setters that should be `get;` only (or use `init`), 1 missing `IDisposable` on a semaphore-owning type, 2 `Dispose` patterns missing `GC.SuppressFinalize`, 2 blocking `.Result` calls in tests.
|
||||
- Impact: Build output is now noisier than before by 8 lines (by design). No runtime impact, no test failures.
|
||||
- Suggestion: Defer to a small dedicated follow-up ticket (one per CA family, or a single grouped ticket) once Phase 7 (Documentation) lands. The C19 spec explicitly defers fixing these to "later runs" ("start with a small named ruleset and expand later"). Not part of AZ-372 scope.
|
||||
- Task: AZ-372 (out-of-scope by design).
|
||||
|
||||
Both findings are Low. Verdict logic: only Low → **PASS_WITH_WARNINGS**.
|
||||
|
||||
## 10. Verdict
|
||||
|
||||
**PASS_WITH_WARNINGS**. Auto-fix gate is bypassed (no Critical/High findings). Both Low findings are informational and out of AZ-372's stated scope. Proceed to commit + push (auto-push enabled) → tracker transition → archive task → loop to next batch.
|
||||
@@ -8,7 +8,7 @@ status: in_progress
|
||||
sub_step:
|
||||
phase: 4
|
||||
name: batch-loop
|
||||
detail: "next batch 22 (AZ-372); session boundary after K=3 review"
|
||||
detail: "batch 22 complete (AZ-372); ready for batch 23"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
|
||||
Reference in New Issue
Block a user