From 0c5eeadbcfc82d897c5db0671c4f9285055ebf08 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:22:45 +0000 Subject: [PATCH 1/4] test: run the repositories against a real PostgreSQL Most backend tests use EF's in-memory database, which has no indexes, transactions, concurrency tokens or bulk updates, so what only PostgreSQL decides went untested. GarageStack.Tests/Postgres runs it against a real server: every migration from an empty database (and back three and forward), the unique trip index and its transaction, hidden trips and joins in SQL, the partial telemetry UPDATE and NOTIFY, the model-derived filters behind the merged status and the daily statistics, concurrent settings saves, the climate schedule version token, and the bulk deletes and updates of housekeeping and notifications. Each test gets a database copied from a template migrated once per run. The server comes from GARAGESTACK_TEST_POSTGRES; without it the tests skip locally and fail in CI, whose backend job now runs a postgres:18 service. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6DtW2eHhipteRopmy3cif --- .github/CONTRIBUTING.md | 8 ++ .github/workflows/ci.yml | 16 +++ documentation/ARCHITECTURE.md | 2 +- .../ClimateScheduleRepositoryPostgresTests.cs | 75 ++++++++++ .../HousekeepingRepositoryPostgresTests.cs | 36 +++++ .../Postgres/MigrationsPostgresTests.cs | 41 ++++++ .../NotificationRepositoryPostgresTests.cs | 58 ++++++++ .../Postgres/PostgresDatabase.cs | 102 ++++++++++++++ .../TelemetryRepositoryPostgresTests.cs | 130 ++++++++++++++++++ .../Postgres/TripRepositoryPostgresTests.cs | 127 +++++++++++++++++ .../UserSettingsRepositoryPostgresTests.cs | 33 +++++ 11 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 src/GarageStack.Tests/Postgres/ClimateScheduleRepositoryPostgresTests.cs create mode 100644 src/GarageStack.Tests/Postgres/HousekeepingRepositoryPostgresTests.cs create mode 100644 src/GarageStack.Tests/Postgres/MigrationsPostgresTests.cs create mode 100644 src/GarageStack.Tests/Postgres/NotificationRepositoryPostgresTests.cs create mode 100644 src/GarageStack.Tests/Postgres/PostgresDatabase.cs create mode 100644 src/GarageStack.Tests/Postgres/TelemetryRepositoryPostgresTests.cs create mode 100644 src/GarageStack.Tests/Postgres/TripRepositoryPostgresTests.cs create mode 100644 src/GarageStack.Tests/Postgres/UserSettingsRepositoryPostgresTests.cs diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 00cdd7eb..a9390083 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -23,6 +23,14 @@ To work against the full stack (real or self-provided credentials): - Run the test suite before opening a PR: - Backend: `dotnet test --project src/GarageStack.Tests` - Frontend: `pnpm test:unit` +- The backend tests in `src/GarageStack.Tests/Postgres` run against a real PostgreSQL, for what the in-memory database cannot show (indexes, concurrency tokens, bulk updates, the SQL a query becomes). Without one they are skipped; CI always runs them. To run them, start a server and say where it is: + + ```bash + docker run -d --name garagestack-test-db -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:18-alpine + GARAGESTACK_TEST_POSTGRES="Host=localhost;Username=postgres;Password=postgres" dotnet test --project src/GarageStack.Tests + ``` + + Each test gets a database of its own, copied from one the migrations build at the start of the run, and drops it afterwards. - Coverage, which CI reports on every run: - Backend: add `--coverage --coverage-output-format cobertura --coverage-settings src/GarageStack.Tests/coverage.settings.xml` to the test command; the report lands in `TestResults/`. - Frontend: `pnpm test:coverage`, with an HTML report in `frontend/coverage/`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f545636..bf18c427 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,6 +172,22 @@ jobs: timeout-minutes: 15 permissions: contents: read + # The PostgreSQL tests (src/GarageStack.Tests/Postgres) run against the same major version the + # images bundle. Without a server they fail here rather than skip. + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 2s + --health-timeout 5s + --health-retries 30 + env: + GARAGESTACK_TEST_POSTGRES: "Host=localhost;Port=5432;Username=postgres;Password=postgres" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md index 4b16f586..bf3eba44 100644 --- a/documentation/ARCHITECTURE.md +++ b/documentation/ARCHITECTURE.md @@ -329,7 +329,7 @@ vector map gets that variant's style; a raster map gets a `data-raster-basemap` ## Tests -Backend tests (xUnit) and frontend unit tests (Vitest) cover logic in isolation. On top of those, +Backend tests (xUnit) and frontend unit tests (Vitest) cover logic in isolation. Most backend tests use EF's in-memory database, which has no indexes, transactions, concurrency tokens or bulk updates, so the tests in `GarageStack.Tests/Postgres` run the repositories against a real PostgreSQL (`PostgresDatabase`: a fresh database per test, copied from a template every migration has been applied to). They need `GARAGESTACK_TEST_POSTGRES`; CI's backend job runs a `postgres:18` service for them and fails rather than skips without it. On top of those, `frontend/e2e/` holds Playwright smoke tests that run against the demo stack from `docker-compose.demo.yml`, which is the production frontend image with the real nginx config. diff --git a/src/GarageStack.Tests/Postgres/ClimateScheduleRepositoryPostgresTests.cs b/src/GarageStack.Tests/Postgres/ClimateScheduleRepositoryPostgresTests.cs new file mode 100644 index 00000000..1599e89d --- /dev/null +++ b/src/GarageStack.Tests/Postgres/ClimateScheduleRepositoryPostgresTests.cs @@ -0,0 +1,75 @@ +using GarageStack.Core.Models; +using GarageStack.Data; +using GarageStack.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace GarageStack.Tests; + +// The Api edits a schedule while the Worker claims its run: the version column decides, so a run +// is never claimed from a schedule that changed since it was read, and an edit is never lost. +public class ClimateScheduleRepositoryPostgresTests +{ + private static async Task AddScheduleAsync(AppDbContext db, CancellationToken ct) + { + var vehicle = new Vehicle { Vin = "FAKEVN00000000001" }; + db.Vehicles.Add(vehicle); + await db.SaveChangesAsync(ct); + return await new ClimateScheduleRepository(db).AddAsync(new ClimateSchedule + { + VehicleId = vehicle.Id, + StartTime = new TimeOnly(7, 30), + TimeZoneId = "Europe/Amsterdam", + NextRunUtc = DateTime.UtcNow.AddMinutes(-1), + }, ct); + } + + [Fact] + public async Task AClaim_OnAScheduleEditedSinceItWasRead_IsLeftForTheNextLook() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var schedule = await AddScheduleAsync(db, ct); + + await using var worker = database.CreateContext(); + var due = Assert.Single(await new ClimateScheduleRepository(worker).GetDueAsync(DateTime.UtcNow, ct)); + await using (var api = database.CreateContext()) + await new ClimateScheduleRepository(api).UpdateAsync(schedule.VehicleId, schedule.Id, s => s.TemperatureC = 23, ct); + + var claimed = await new ClimateScheduleRepository(worker).TryClaimAsync(due, s => s.NextRunUtc = null, ct); + + await using var check = database.CreateContext(); + var saved = await check.ClimateSchedules.SingleAsync(ct); + Assert.False(claimed); + Assert.Equal(23, saved.TemperatureC); + Assert.NotNull(saved.NextRunUtc); + } + + [Fact] + public async Task AnEdit_RacingAClaim_IsAppliedOnTopOfIt() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var schedule = await AddScheduleAsync(db, ct); + + // The Api has the schedule loaded; the Worker claims the run before the Api saves its edit. + await using var api = database.CreateContext(); + var stale = await api.ClimateSchedules.SingleAsync(ct); + await using (var worker = database.CreateContext()) + { + var due = Assert.Single(await new ClimateScheduleRepository(worker).GetDueAsync(DateTime.UtcNow, ct)); + Assert.True(await new ClimateScheduleRepository(worker).TryClaimAsync(due, s => s.LastRunOutcome = ClimateScheduleOutcome.Running, ct)); + } + stale.TemperatureC = 25; + stale.Version++; + await Assert.ThrowsAsync(() => api.SaveChangesAsync(ct)); + api.ChangeTracker.Clear(); + + await new ClimateScheduleRepository(api).UpdateAsync(schedule.VehicleId, schedule.Id, s => s.TemperatureC = 25, ct); + + await using var check = database.CreateContext(); + var saved = await check.ClimateSchedules.SingleAsync(ct); + Assert.Equal((25, ClimateScheduleOutcome.Running), (saved.TemperatureC, saved.LastRunOutcome)); + } +} diff --git a/src/GarageStack.Tests/Postgres/HousekeepingRepositoryPostgresTests.cs b/src/GarageStack.Tests/Postgres/HousekeepingRepositoryPostgresTests.cs new file mode 100644 index 00000000..98c7ea91 --- /dev/null +++ b/src/GarageStack.Tests/Postgres/HousekeepingRepositoryPostgresTests.cs @@ -0,0 +1,36 @@ +using GarageStack.Core.Models; +using GarageStack.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace GarageStack.Tests; + +// On PostgreSQL the housekeeping deletes run as one DELETE each (ExecuteDelete), a path the +// in-memory tests cannot take. +public class HousekeepingRepositoryPostgresTests +{ + [Fact] + public async Task ExpiredCachesAndOldDeletedNotifications_GoInOneStatementEach() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var now = DateTime.UtcNow; + db.GeocodeCacheEntries.AddRange( + new GeocodeCacheEntry { Precision = "street", Language = "en", CellLat = 1, CellLng = 1, ExpiresAt = now.AddDays(-2) }, + new GeocodeCacheEntry { Precision = "street", Language = "en", CellLat = 2, CellLng = 2, ExpiresAt = now.AddDays(5) }); + db.AppNotifications.AddRange( + new AppNotification { Title = "Old, deleted", IsDeleted = true, CreatedAt = now.AddDays(-40) }, + new AppNotification { Title = "Old, kept", CreatedAt = now.AddDays(-40) }, + new AppNotification { Title = "New, deleted", IsDeleted = true, CreatedAt = now }); + await db.SaveChangesAsync(ct); + var repo = new HousekeepingRepository(db); + + var caches = await repo.RemoveExpiredCacheEntriesAsync(now, ct); + var notifications = await repo.RemoveDeletedNotificationsAsync(now.AddDays(-30), ct); + + await using var check = database.CreateContext(); + Assert.Equal((1, 1), (caches, notifications)); + Assert.Equal(1, await check.GeocodeCacheEntries.CountAsync(ct)); + Assert.Equal(["New, deleted", "Old, kept"], await check.AppNotifications.OrderBy(n => n.Title).Select(n => n.Title).ToListAsync(ct)); + } +} diff --git a/src/GarageStack.Tests/Postgres/MigrationsPostgresTests.cs b/src/GarageStack.Tests/Postgres/MigrationsPostgresTests.cs new file mode 100644 index 00000000..08c86a3c --- /dev/null +++ b/src/GarageStack.Tests/Postgres/MigrationsPostgresTests.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; + +namespace GarageStack.Tests; + +// Every migration, from the first to the newest, applied to an empty PostgreSQL database: what a +// new install does on its first start, and what an upgrade does in parts. +public class MigrationsPostgresTests +{ + [Fact] + public async Task EveryMigration_AppliesToAnEmptyDatabase_AndLeavesNoneToRun() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + + var applied = await db.Database.GetAppliedMigrationsAsync(ct); + var pending = await db.Database.GetPendingMigrationsAsync(ct); + + Assert.Equal(db.Database.GetMigrations(), applied); + Assert.Empty(pending); + } + + [Fact] + public async Task TheNewestMigrations_CanBeTakenBackAndAppliedAgain() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var migrations = db.Database.GetMigrations().ToList(); + var migrator = db.GetInfrastructure().GetRequiredService(); + + // Back to before the last three, which rolling back a release would do, and forwards again. + await migrator.MigrateAsync(migrations[^4], cancellationToken: ct); + await migrator.MigrateAsync(cancellationToken: ct); + + Assert.Empty(await db.Database.GetPendingMigrationsAsync(ct)); + } +} diff --git a/src/GarageStack.Tests/Postgres/NotificationRepositoryPostgresTests.cs b/src/GarageStack.Tests/Postgres/NotificationRepositoryPostgresTests.cs new file mode 100644 index 00000000..a8c7f64d --- /dev/null +++ b/src/GarageStack.Tests/Postgres/NotificationRepositoryPostgresTests.cs @@ -0,0 +1,58 @@ +using GarageStack.Core.Models; +using GarageStack.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace GarageStack.Tests; + +// Archiving and deleting every notification at once are bulk updates (ExecuteUpdate), which only +// a relational database runs. +public class NotificationRepositoryPostgresTests +{ + [Fact] + public async Task ArchiveAll_AndDeleteAll_ChangeEveryRowTheyShould() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + db.AppNotifications.AddRange( + new AppNotification { Title = "Unread" }, + new AppNotification { Title = "Read", IsArchived = true }, + new AppNotification { Title = "Gone", IsDeleted = true }); + await db.SaveChangesAsync(ct); + var repo = new NotificationRepository(db); + + await repo.ArchiveAllAsync(ct); + var unreadAfterArchive = await repo.CountUnreadAsync(ct); + await repo.DeleteAllAsync(ct); + + await using var check = database.CreateContext(); + Assert.Equal(0, unreadAfterArchive); + Assert.Empty(await new NotificationRepository(check).ListAsync(10, ct)); + Assert.Equal(3, await check.AppNotifications.CountAsync(n => n.IsDeleted, ct)); + } + + [Fact] + public async Task TheCooldownLookup_FindsASendForThatCarAndCategoryOnly() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var now = DateTime.UtcNow; + var vehicle = new Vehicle { Vin = "FAKEVN00000000001" }; + db.Vehicles.Add(vehicle); + await db.SaveChangesAsync(ct); + db.AppNotifications.Add(new AppNotification + { + Title = "Low tyre", + Category = NotificationCategories.LowTyre, + VehicleId = vehicle.Id, + CreatedAt = now.AddMinutes(-10), + }); + await db.SaveChangesAsync(ct); + var repo = new NotificationRepository(db); + + Assert.True(await repo.WasSentSinceAsync(NotificationCategories.LowTyre, vehicle.Id, now.AddHours(-1), ct)); + Assert.False(await repo.WasSentSinceAsync(NotificationCategories.LowFuel, vehicle.Id, now.AddHours(-1), ct)); + Assert.False(await repo.WasSentSinceAsync(NotificationCategories.LowTyre, vehicle.Id, now.AddMinutes(-5), ct)); + } +} diff --git a/src/GarageStack.Tests/Postgres/PostgresDatabase.cs b/src/GarageStack.Tests/Postgres/PostgresDatabase.cs new file mode 100644 index 00000000..48e00fe4 --- /dev/null +++ b/src/GarageStack.Tests/Postgres/PostgresDatabase.cs @@ -0,0 +1,102 @@ +using GarageStack.Data; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace GarageStack.Tests; + +/// +/// A database of its own on a real PostgreSQL server, for the tests the in-memory provider cannot +/// answer: unique and filtered indexes, concurrency tokens, bulk updates and deletes, the SQL a +/// LINQ query becomes, and NOTIFY. Each one is a copy of a template the migrations built once per +/// test run, so a test starts from the schema a deployment has and costs a CREATE DATABASE. +/// +/// The server is , a connection string for a role that may create +/// databases, such as Host=localhost;Username=postgres;Password=postgres. Without it a test +/// is skipped, except in CI (CI=true), where the job provides one and a missing one fails: +/// these tests must never pass there by not running. +/// +/// +internal sealed class PostgresDatabase : IAsyncDisposable +{ + public const string ConnectionVariable = "GARAGESTACK_TEST_POSTGRES"; + + private const string TemplateName = "garagestack_test_template"; + private static readonly SemaphoreSlim TemplateGate = new(1, 1); + private static bool _templateReady; + + private readonly string _adminConnection; + private readonly string _name; + + private PostgresDatabase(string adminConnection, string name) + { + _adminConnection = adminConnection; + _name = name; + ConnectionString = new NpgsqlConnectionStringBuilder(adminConnection) { Database = name }.ConnectionString; + } + + public string ConnectionString { get; } + + public static async Task CreateAsync(CancellationToken ct) + { + var admin = Environment.GetEnvironmentVariable(ConnectionVariable); + if (string.IsNullOrWhiteSpace(admin)) + { + const string howTo = $"set {ConnectionVariable} to a PostgreSQL server, for example " + + "docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:18-alpine and " + + $"{ConnectionVariable}=\"Host=localhost;Username=postgres;Password=postgres\""; + if (string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase)) + Assert.Fail($"The PostgreSQL tests need a server in CI: {howTo}"); + Assert.Skip($"Needs PostgreSQL: {howTo}"); + } + + var name = $"garagestack_test_{Guid.NewGuid():N}"[..40]; + // One copy at a time: PostgreSQL refuses to copy a template another session is reading. + await TemplateGate.WaitAsync(ct); + try + { + await EnsureTemplateAsync(admin, ct); + await ExecuteAsync(admin, $"""CREATE DATABASE "{name}" TEMPLATE "{TemplateName}" """, ct); + } + finally + { + TemplateGate.Release(); + } + return new PostgresDatabase(admin, name); + } + + /// A context on this database, as the services get one. Dispose it. + public AppDbContext CreateContext() => + new(new DbContextOptionsBuilder().UseNpgsql(ConnectionString).Options); + + public async ValueTask DisposeAsync() + { + // A pooled connection still open on the database would keep it from being dropped. + NpgsqlConnection.ClearAllPools(); + await ExecuteAsync(_adminConnection, $"""DROP DATABASE IF EXISTS "{_name}" WITH (FORCE)""", CancellationToken.None); + } + + // Built afresh by the first test of each run, so a migration added since the last run is in it. + // Called holding TemplateGate. + private static async Task EnsureTemplateAsync(string admin, CancellationToken ct) + { + if (_templateReady) return; + + await ExecuteAsync(admin, $"""DROP DATABASE IF EXISTS "{TemplateName}" WITH (FORCE)""", ct); + await ExecuteAsync(admin, $"""CREATE DATABASE "{TemplateName}" """, ct); + var template = new NpgsqlConnectionStringBuilder(admin) { Database = TemplateName }.ConnectionString; + await using (var db = new AppDbContext(new DbContextOptionsBuilder().UseNpgsql(template).Options)) + await db.Database.MigrateAsync(ct); + + // A template cannot be copied while anything is connected to it. + NpgsqlConnection.ClearAllPools(); + _templateReady = true; + } + + private static async Task ExecuteAsync(string connectionString, string sql, CancellationToken ct) + { + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(ct); + } +} diff --git a/src/GarageStack.Tests/Postgres/TelemetryRepositoryPostgresTests.cs b/src/GarageStack.Tests/Postgres/TelemetryRepositoryPostgresTests.cs new file mode 100644 index 00000000..c2b80552 --- /dev/null +++ b/src/GarageStack.Tests/Postgres/TelemetryRepositoryPostgresTests.cs @@ -0,0 +1,130 @@ +using GarageStack.Core.Models; +using GarageStack.Data; +using GarageStack.Data.Extensions; +using GarageStack.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace GarageStack.Tests; + +// The telemetry queries as PostgreSQL runs them: the "any field set" filters built from the model, +// the partial update a merged MQTT message makes, and the NOTIFY every write sends the Api. +public class TelemetryRepositoryPostgresTests +{ + private static readonly DateTime Now = DateTime.UtcNow; + + private static async Task AddVehicleAsync(AppDbContext db, CancellationToken ct) + { + var vehicle = new Vehicle { Vin = "FAKEVN00000000001" }; + db.Vehicles.Add(vehicle); + await db.SaveChangesAsync(ct); + return vehicle.Id; + } + + [Fact] + public async Task AMergedMessage_UpdatesOnlyItsOwnFields() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var vehicleId = await AddVehicleAsync(db, ct); + var id = await new TelemetryRepository(db).AddAsync( + new TelemetrySnapshot { VehicleId = vehicleId, RecordedAt = Now, FuelLevelPercent = 60, BatteryVoltage = 12.6 }, ct); + + await using (var merging = database.CreateContext()) + await new TelemetryRepository(merging).MergeIntoAsync( + id, new TelemetrySnapshot { VehicleId = vehicleId, FuelLevelPercent = 55, EvSocPercent = 80 }, ct); + + await using var check = database.CreateContext(); + var row = await check.TelemetrySnapshots.SingleAsync(ct); + Assert.Equal((55.0, 80.0, 12.6), (row.FuelLevelPercent, row.EvSocPercent, row.BatteryVoltage)); + } + + [Fact] + public async Task AMessageForARowThatIsGone_BecomesARowOfItsOwn() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var vehicleId = await AddVehicleAsync(db, ct); + + await new TelemetryRepository(db).MergeIntoAsync( + 999_999, new TelemetrySnapshot { VehicleId = vehicleId, RecordedAt = Now, FuelLevelPercent = 70 }, ct); + + await using var check = database.CreateContext(); + Assert.Equal(70, (await check.TelemetrySnapshots.SingleAsync(ct)).FuelLevelPercent); + } + + [Fact] + public async Task TheMergedLatest_FillsInThePositionAndTheSchedules_FromOlderRows() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var vehicleId = await AddVehicleAsync(db, ct); + db.TelemetrySnapshots.Add(new TelemetrySnapshot + { + VehicleId = vehicleId, + RecordedAt = Now.AddDays(-20), + ChargingScheduleMode = "UNTIL_CONFIGURED_TIME", + }); + db.TelemetrySnapshots.Add(new TelemetrySnapshot { VehicleId = vehicleId, RecordedAt = Now.AddDays(-10), Latitude = 52.37, Longitude = 4.89 }); + db.TelemetrySnapshots.AddRange(Enumerable.Range(0, 250).Select(i => new TelemetrySnapshot + { + VehicleId = vehicleId, + RecordedAt = Now.AddMinutes(-i), + EvSocPercent = 70, + BatteryVoltage = 12.5, + })); + await db.SaveChangesAsync(ct); + + var merged = await new TelemetryRepository(db).GetMergedLatestAsync(vehicleId, ct); + + Assert.Equal((70.0, 52.37, 4.89), (merged!.EvSocPercent, merged.Latitude, merged.Longitude)); + Assert.Equal("UNTIL_CONFIGURED_TIME", merged.ChargingScheduleMode); + } + + [Fact] + public async Task TheDailyStatistics_ReadEveryChartFieldPerLocalDay() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var vehicleId = await AddVehicleAsync(db, ct); + var amsterdam = TimeZoneInfo.FindSystemTimeZoneById("Europe/Amsterdam"); + var day = new DateOnly(2026, 3, 2); + var nine = new DateTime(2026, 3, 2, 8, 0, 0, DateTimeKind.Utc); + db.TelemetrySnapshots.AddRange( + new TelemetrySnapshot { VehicleId = vehicleId, RecordedAt = nine, EvSocPercent = 50, PowerUsageOfDay = 1.5, TyrePressureFrontLeft = 2.4 }, + new TelemetrySnapshot { VehicleId = vehicleId, RecordedAt = nine.AddHours(4), EvSocPercent = 30, PowerUsageOfDay = 4.0 }, + // A position alone says nothing about the day's figures. + new TelemetrySnapshot { VehicleId = vehicleId, RecordedAt = nine.AddHours(5), Latitude = 52, Longitude = 5 }); + await db.SaveChangesAsync(ct); + + var stats = await new TelemetryRepository(db).GetDailyStatsAsync(vehicleId, day, day, amsterdam, day.AddDays(1), ct); + + var stat = Assert.Single(stats); + Assert.Equal((40.0, 4.0, 2.4), (stat.EvSocPercent, stat.CounterTotal, stat.TyrePressureFrontLeft)); + } + + [Fact] + public async Task EveryWrite_TellsTheApi_OverNotify() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var vehicleId = await AddVehicleAsync(db, ct); + await using var listener = new NpgsqlConnection(database.ConnectionString); + await listener.OpenAsync(ct); + var heard = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + listener.Notification += (_, e) => heard.TrySetResult(e); + await using (var listen = new NpgsqlCommand($"LISTEN {PgChannels.TelemetryUpdated}", listener)) + await listen.ExecuteNonQueryAsync(ct); + + await new TelemetryRepository(db).AddAsync(new TelemetrySnapshot { VehicleId = vehicleId, RecordedAt = Now, EvSocPercent = 1 }, ct); + await listener.WaitAsync(TimeSpan.FromSeconds(5), ct); + + var notification = await heard.Task.WaitAsync(TimeSpan.FromSeconds(5), ct); + Assert.Equal((PgChannels.TelemetryUpdated, vehicleId.ToString()), (notification.Channel, notification.Payload)); + } +} diff --git a/src/GarageStack.Tests/Postgres/TripRepositoryPostgresTests.cs b/src/GarageStack.Tests/Postgres/TripRepositoryPostgresTests.cs new file mode 100644 index 00000000..d3bab797 --- /dev/null +++ b/src/GarageStack.Tests/Postgres/TripRepositoryPostgresTests.cs @@ -0,0 +1,127 @@ +using GarageStack.Core.Helpers; +using GarageStack.Core.Models; +using GarageStack.Data; +using GarageStack.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace GarageStack.Tests; + +// What only PostgreSQL decides about trips: the unique index that turns a slip in the recording +// line into an error, the transaction around saving and joining, and the SQL the filters become. +public class TripRepositoryPostgresTests +{ + private static readonly DateTime T0 = new(2026, 1, 5, 8, 0, 0, DateTimeKind.Utc); + + private static async Task AddVehicleAsync(AppDbContext db, CancellationToken ct) + { + var vehicle = new Vehicle { Vin = "FAKEVN00000000001" }; + db.Vehicles.Add(vehicle); + await db.SaveChangesAsync(ct); + return vehicle.Id; + } + + private static TripRepository Repository(AppDbContext db) => new(db, new TelemetryRepository(db)); + + // A drive north from one latitude to another, a fix every five minutes. + private static RecordedTrip Leg(DateTime start, double from, double to, double? odometerStart = null, double? odometerEnd = null) + { + TripPoint[] fixes = + [ + new(start, from, 5.0, 40), + new(start.AddMinutes(5), (from + to) / 2, 5.0, 90), + new(start.AddMinutes(10), to, 5.0, 50), + ]; + return new RecordedTrip( + new TripDto(0, start, start.AddMinutes(10), GeoHelper.Haversine(from, 5.0, to, 5.0), fixes.Length, fixes), + odometerStart, odometerEnd); + } + + [Fact] + public async Task ATripSavedTwice_IsRefused_AndTheLineDoesNotMove() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var vehicleId = await AddVehicleAsync(db, ct); + await Repository(db).SaveRecordedAsync(vehicleId, [Leg(T0, 52.0, 52.1)], T0.AddHours(1), ct); + + await using var again = database.CreateContext(); + await Assert.ThrowsAsync(() => + Repository(again).SaveRecordedAsync(vehicleId, [Leg(T0, 52.0, 52.1)], T0.AddHours(2), ct)); + + await using var check = database.CreateContext(); + Assert.Equal(1, await check.Trips.CountAsync(ct)); + Assert.Equal(T0.AddHours(1), await Repository(check).GetRecordedUntilAsync(vehicleId, ct)); + } + + [Fact] + public async Task HiddenTrips_AreLeftOutByTheQueries_AndListedInTheLog() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var vehicleId = await AddVehicleAsync(db, ct); + var repo = Repository(db); + await repo.SaveRecordedAsync(vehicleId, [Leg(T0, 52.0, 52.1), Leg(T0.AddHours(2), 52.1, 52.2)], T0.AddHours(3), ct); + var ids = await db.Trips.OrderBy(t => t.StartedAt).Select(t => t.Id).ToListAsync(ct); + + await repo.SetHiddenAsync(vehicleId, ids[1], hidden: true, ct); + + await using var check = database.CreateContext(); + var read = Repository(check); + Assert.Equal([ids[0]], (await read.GetTripsAsync(vehicleId, T0.AddDays(-1), T0.AddDays(1), ct)).Select(t => t.Id)); + Assert.Equal([ids[0]], (await read.GetSummariesAsync(vehicleId, T0.AddDays(-1), T0.AddDays(1), ct)).Select(t => t.Id)); + Assert.Equal(ids[0], (await read.GetLatestAsync(vehicleId, T0.AddHours(4), ct))!.Id); + Assert.Equal([false, true], (await read.GetLogAsync(vehicleId, T0.AddDays(-1), T0.AddDays(1), ct)).Select(e => e.IsHidden)); + } + + [Fact] + public async Task Joining_ReplacesTwoRowsWithOne_UnderTheFirstOnesId() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var vehicleId = await AddVehicleAsync(db, ct); + db.CommutePlaces.AddRange( + new CommutePlace { VehicleId = vehicleId, Kind = CommutePlaceKind.Home, Latitude = 52.0, Longitude = 5.0 }, + new CommutePlace { VehicleId = vehicleId, Kind = CommutePlaceKind.Work, Latitude = 52.2, Longitude = 5.0 }); + await db.SaveChangesAsync(ct); + await Repository(db).SaveRecordedAsync(vehicleId, + [Leg(T0, 52.0, 52.1, 1000, 1011), Leg(T0.AddMinutes(20), 52.1, 52.2, 1011, 1022)], T0.AddHours(1), ct); + var ids = await db.Trips.OrderBy(t => t.StartedAt).Select(t => t.Id).ToListAsync(ct); + + await using var joining = database.CreateContext(); + var result = await Repository(joining).MergeWithNextAsync(vehicleId, ids[0], ct); + + await using var check = database.CreateContext(); + var trip = Assert.Single(await check.Trips.ToListAsync(ct)); + Assert.Equal(TripMergeOutcome.Merged, result.Outcome); + Assert.Equal(ids[0], trip.Id); + Assert.Equal((T0, T0.AddMinutes(30), 6), (trip.StartedAt, trip.EndedAt, trip.PointCount)); + Assert.Equal((1000.0, 1022.0), (trip.OdometerStartKm, trip.OdometerEndKm)); + Assert.Equal(TripPurpose.Commute, trip.Purpose); + Assert.Equal(6, Assert.IsType>(SafeJson.TryDeserialize>(trip.PointsJson)).Count); + } + + [Fact] + public async Task Relabelling_MarksTheTripsBetweenHomeAndWork() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + await using var db = database.CreateContext(); + var vehicleId = await AddVehicleAsync(db, ct); + var repo = Repository(db); + await repo.SaveRecordedAsync(vehicleId, [Leg(T0, 52.0, 52.2), Leg(T0.AddHours(2), 52.5, 52.6)], T0.AddHours(3), ct); + CommutePlace[] places = + [ + new() { VehicleId = vehicleId, Kind = CommutePlaceKind.Home, Latitude = 52.0, Longitude = 5.0 }, + new() { VehicleId = vehicleId, Kind = CommutePlaceKind.Work, Latitude = 52.2, Longitude = 5.0 }, + ]; + + var relabel = await repo.RelabelCommutesAsync(vehicleId, places, ct); + + Assert.Equal(new CommuteRelabel(1, 0), relabel); + await using var check = database.CreateContext(); + Assert.Equal([TripPurpose.Commute, null], await check.Trips.OrderBy(t => t.StartedAt).Select(t => t.Purpose).ToListAsync(ct)); + } +} diff --git a/src/GarageStack.Tests/Postgres/UserSettingsRepositoryPostgresTests.cs b/src/GarageStack.Tests/Postgres/UserSettingsRepositoryPostgresTests.cs new file mode 100644 index 00000000..935a4877 --- /dev/null +++ b/src/GarageStack.Tests/Postgres/UserSettingsRepositoryPostgresTests.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using GarageStack.Core.Models; +using GarageStack.Data.Repositories; + +namespace GarageStack.Tests; + +// Devices saving the same settings section at the same moment, on a database that really refuses +// the losing write: the version column and the retry are what keep every device's keys. +public class UserSettingsRepositoryPostgresTests +{ + private static Dictionary Change(string key, string value) => + new() { [key] = JsonSerializer.SerializeToElement(value) }; + + [Fact] + public async Task SavesAtTheSameMoment_KeepEveryDevicesKeys() + { + var ct = TestContext.Current.CancellationToken; + await using var database = await PostgresDatabase.CreateAsync(ct); + const int devices = 8; + + var saves = Enumerable.Range(0, devices).Select(async i => + { + await using var db = database.CreateContext(); + return await new UserSettingsRepository(db).MergeAsync("account", "ui", Change($"key{i}", $"value{i}"), ct); + }); + var results = await Task.WhenAll(saves); + + await using var check = database.CreateContext(); + var saved = (await new UserSettingsRepository(check).GetAsync("account", ct))["ui"]; + Assert.All(results, r => Assert.Equal(SettingsSaveResult.Saved, r)); + Assert.Equal(devices, saved.EnumerateObject().Count()); + } +} From e106c1702d00dc3139039b381cc0c59d22f1199c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:35:05 +0000 Subject: [PATCH 2/4] test(frontend): cover the dashboard, statistics, map and trip log views and the map layers Specs for the four views that had none (what each loads, when it loads again, its empty and error states, hiding and joining trips) and for useTripLayers and usePoiLayers, with Leaflet and the tile layers replaced by recorders. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6DtW2eHhipteRopmy3cif --- .../__tests__/usePoiLayers.spec.ts | 270 +++++++++++++++++ .../__tests__/useTripLayers.spec.ts | 277 ++++++++++++++++++ .../src/views/__tests__/DashboardView.spec.ts | 180 ++++++++++++ frontend/src/views/__tests__/MapView.spec.ts | 263 +++++++++++++++++ .../views/__tests__/StatisticsView.spec.ts | 235 +++++++++++++++ .../src/views/__tests__/TripLogView.spec.ts | 176 +++++++++++ 6 files changed, 1401 insertions(+) create mode 100644 frontend/src/composables/__tests__/usePoiLayers.spec.ts create mode 100644 frontend/src/composables/__tests__/useTripLayers.spec.ts create mode 100644 frontend/src/views/__tests__/DashboardView.spec.ts create mode 100644 frontend/src/views/__tests__/MapView.spec.ts create mode 100644 frontend/src/views/__tests__/StatisticsView.spec.ts create mode 100644 frontend/src/views/__tests__/TripLogView.spec.ts diff --git a/frontend/src/composables/__tests__/usePoiLayers.spec.ts b/frontend/src/composables/__tests__/usePoiLayers.spec.ts new file mode 100644 index 00000000..f6e754a0 --- /dev/null +++ b/frontend/src/composables/__tests__/usePoiLayers.spec.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { computed, defineComponent, nextTick, ref, shallowRef } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createI18n } from 'vue-i18n' +import en from '@/locales/en.json' +import type { LeafletMap } from '@/utils/leaflet' +import type { VehicleType } from '@/stores/vehicle' +import { useMapSettingsStore } from '@/stores/settingsMap' +import { mapApi, type ChargingStation, type PoiItem } from '@/services/mapApi' +import type { TileLayerOptions } from '../poiTileLayer' +import { usePoiLayers } from '../usePoiLayers' + +// The tile layers' fetching and drawing has its own suite (poiTileLayer.spec.ts). Here each layer +// is a stand-in that keeps the options it was built with, which is where this composable's rules +// live: when a layer applies, what its filters keep, and what its popups say. +interface FakeLayer { + options: TileLayerOptions + items: Map + load: Mock<(radiusKm?: number) => void> + scheduleLoad: Mock<(delayMs: number) => void> + redraw: Mock<() => void> + dispose: Mock<() => void> +} +const layers: FakeLayer[] = [] + +vi.mock('../poiTileLayer', async (importOriginal) => ({ + escapeHtml: (await importOriginal()).escapeHtml, + createMarker: ( + _lat: number, + _lng: number, + className: string, + _glyph: string, + popup: () => string, + ) => ({ + className, + popup, + }), + createTileLayer: (options: TileLayerOptions) => { + const layer = { + options, + items: new Map(), + load: vi.fn<(radiusKm?: number) => void>(), + scheduleLoad: vi.fn<(delayMs: number) => void>(), + redraw: vi.fn<() => void>(), + dispose: vi.fn<() => void>(), + } + layers.push(layer) + return layer + }, +})) + +vi.mock('../useLayerCredit', () => ({ useLayerCredit: () => undefined })) + +vi.mock('@/services/mapApi', () => ({ + mapApi: { + chargingStations: vi.fn(), + poi: vi.fn(), + poiBrands: vi.fn(), + }, +})) + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) + +// In the order the composable builds them. +const charging = () => + layers[0]! as unknown as FakeLayer & { options: TileLayerOptions } +const fuel = () => layers[1]! as unknown as FakeLayer & { options: TileLayerOptions } +const speedCameras = () => + layers[3]! as unknown as FakeLayer & { options: TileLayerOptions } + +function station(kw: (number | null)[], overrides: Partial = {}): ChargingStation { + return { + id: 1, + title: 'Laadplein', + latitude: 52, + longitude: 5, + operator: null, + addressLine: null, + town: null, + numberOfPoints: null, + isOperational: true, + connectors: kw.map((powerKw) => ({ type: 'Type 2', powerKw, quantity: 1 })), + ...overrides, + } as ChargingStation +} + +function poi(tags: Record, overrides: Partial = {}): PoiItem { + return { + externalId: 'node/1', + poiType: 'fuel', + latitude: 52, + longitude: 5, + name: 'Tankstation', + tags, + ...overrides, + } as PoiItem +} + +function fakeMap() { + const handlers = new Map void>() + return { + handlers, + on: vi.fn<(events: string, handler: () => void) => void>((events, handler) => { + handlers.set(events, handler) + }), + off: vi.fn<(events: string, handler: () => void) => void>((events) => { + handlers.delete(events) + }), + } +} + +function setup(type: VehicleType, map: ReturnType | null = fakeMap()) { + const vehicleType = ref(type) + const mapInstance = shallowRef(null) + let result!: ReturnType + const wrapper = mount( + defineComponent({ + setup() { + result = usePoiLayers({ mapInstance, vehicleType: computed(() => vehicleType.value) }) + return () => null + }, + }), + { global: { plugins: [i18n] } }, + ) + mapInstance.value = map as unknown as LeafletMap + return { wrapper, result, vehicleType, mapInstance, map } +} + +describe('usePoiLayers', () => { + beforeEach(() => { + layers.length = 0 + localStorage.clear() + setActivePinia(createPinia()) + vi.clearAllMocks() + vi.mocked(mapApi.poiBrands).mockResolvedValue([]) + }) + + it('offers charging stations only to a car with a plug, and fuel stations only to one with a tank', () => { + const settings = useMapSettingsStore() + settings.chargingStationsEnabled = true + settings.fuelStationsEnabled = true + + setup('hev') + expect(charging().options.enabled.value).toBe(false) + expect(fuel().options.enabled.value).toBe(true) + + layers.length = 0 + setup('bev') + expect(charging().options.enabled.value).toBe(true) + expect(fuel().options.enabled.value).toBe(false) + }) + + it('keeps the charging stations within the power range, and redraws when it changes', async () => { + const settings = useMapSettingsStore() + setup('bev') + const keeps = (s: ChargingStation) => charging().options.include!(s) + + settings.chargingMinPowerKw = 50 + settings.chargingMaxPowerKw = 150 + await nextTick() + + expect(keeps(station([11, 22]))).toBe(false) + expect(keeps(station([11, 100]))).toBe(true) + expect(keeps(station([350]))).toBe(false) + // An unknown power cannot be ruled out by the upper bound. + expect(keeps(station([null, 50]))).toBe(true) + expect(charging().redraw).toHaveBeenCalled() + }) + + it('keeps the fuel stations that pass both the brand and the fuel type, and redraws when either changes', async () => { + const settings = useMapSettingsStore() + setup('hev') + const keeps = (item: PoiItem) => fuel().options.include!(item) + + settings.fuelBrandFilter = ['BP'] + settings.fuelTypeFilter = ['diesel'] + await nextTick() + + // A brand variant counts as its brand. + expect(keeps(poi({ brand: 'BP Express', 'fuel:diesel': 'yes' }))).toBe(true) + expect(keeps(poi({ brand: 'BP', 'fuel:diesel': 'no', 'fuel:octane_95': 'yes' }))).toBe(false) + expect(keeps(poi({ brand: 'Shell', 'fuel:diesel': 'yes' }))).toBe(false) + expect(keeps(poi({ 'fuel:diesel': 'yes' }))).toBe(false) + expect(fuel().redraw).toHaveBeenCalled() + }) + + it("loads the brand list for a car that burns fuel, and offers it with the stations' own brands", async () => { + vi.mocked(mapApi.poiBrands).mockResolvedValue(['Shell', 'BP Express']) + const { result } = setup('phev') + await flushPromises() + fuel().items.set('node/2', poi({ brand: 'Tango' })) + fuel().items.set('node/3', poi({ brand: 'BP' })) + + expect(mapApi.poiBrands).toHaveBeenCalledWith('fuel', 'phev') + expect(result.availableFuelBrands.value).toEqual(['BP', 'Shell', 'Tango']) + }) + + it('loads no brand list for a car that burns none', async () => { + setup('bev') + await flushPromises() + + expect(mapApi.poiBrands).not.toHaveBeenCalled() + }) + + it('waits for the vehicle type before loading fuel stations, and loads them once it is known', async () => { + const { vehicleType } = setup('unknown') + expect(fuel().options.ready!()).toBe(false) + + vehicleType.value = 'hev' + await nextTick() + + expect(fuel().options.ready!()).toBe(true) + expect(fuel().load).toHaveBeenCalledOnce() + }) + + it('drops the speed camera layer once the server says it does not serve one', async () => { + useMapSettingsStore().speedCamerasEnabled = true + vi.mocked(mapApi.poi).mockResolvedValue({ items: [], hasMore: false, available: false }) + const { result } = setup('hev') + expect(speedCameras().options.enabled.value).toBe(true) + + await speedCameras().options.fetchItems({ lat: 52, lng: 5 } as never, 10) + + expect(result.speedCamerasAvailable.value).toBe(false) + expect(speedCameras().options.enabled.value).toBe(false) + }) + + it('sums the connectors of a kind in the charging popup, and escapes what OCM sends', () => { + setup('bev') + const marker = charging().options.toMarker( + station([22, 22, 50], { + title: 'Laadplein', + numberOfPoints: 3, + town: 'Utrecht', + addressLine: 'Stationsplein 1', + }), + ) as unknown as { popup: () => string } + + const html = marker.popup() + expect(html).toContain('<b>Laadplein</b>') + expect(html).toContain('
  • Type 2 · 22 kW ×2
  • ') + expect(html).toContain('
  • Type 2 · 50 kW
  • ') + expect(html).toContain('3 stalls') + expect(html).toContain('Stationsplein 1, Utrecht') + }) + + it("lists a fuel station's fuels in its popup", () => { + setup('hev') + const marker = fuel().options.toMarker( + poi({ brand: 'Shell', 'fuel:diesel': 'yes', 'fuel:lpg': 'yes' }), + ) as unknown as { popup: () => string } + + const html = marker.popup() + expect(html).toContain('Shell') + expect(html).toContain('Diesel · LPG') + }) + + it('loads again a moment after the map moves, and lets go of the map and the layers when done', async () => { + const { wrapper, map } = setup('hev') + await nextTick() + + map!.handlers.get('moveend zoomend')!() + for (const layer of layers) expect(layer.scheduleLoad).toHaveBeenCalledWith(500) + + wrapper.unmount() + expect(map!.off).toHaveBeenCalledWith('moveend zoomend', expect.any(Function)) + for (const layer of layers) expect(layer.dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/composables/__tests__/useTripLayers.spec.ts b/frontend/src/composables/__tests__/useTripLayers.spec.ts new file mode 100644 index 00000000..50e5e16b --- /dev/null +++ b/frontend/src/composables/__tests__/useTripLayers.spec.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { defineComponent, nextTick, ref, shallowRef } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import type { LeafletMap } from '@/utils/leaflet' +import type { TelemetrySnapshot, Trip, TripPoint } from '@/services/vehicleApi' +import type { TripMatch } from '../useTripMatch' +import { useMapSettingsStore } from '@/stores/settingsMap' +import { fitToPoints } from '@/utils/leaflet' +import { moveCarMarker } from '@/utils/mapCarIcon' +import { useTripLayers, type TripLayersOptions } from '../useTripLayers' + +// Leaflet is a recorder here: every line and marker on the map is a plain object in `drawn`, so a +// test reads the picture off it rather than off a canvas. +interface Drawn { + kind: 'line' | 'marker' | 'heat' + latLngs: unknown + options: Record + click?: () => void +} +const drawn: Drawn[] = [] + +function layer(kind: Drawn['kind'], latLngs: unknown, options: Record = {}) { + const entry: Drawn = { kind, latLngs, options } + return { + entry, + on(_event: string, handler: () => void) { + entry.click = handler + return this + }, + addTo() { + drawn.push(entry) + return this + }, + remove() { + drawn.splice(drawn.indexOf(entry), 1) + }, + } +} + +vi.mock('@/utils/leaflet', () => ({ + L: { + polyline: (latLngs: unknown, options: Record) => + layer('line', latLngs, options), + marker: (latLng: unknown, options: { icon: { html?: string } }) => + layer('marker', latLng, { html: options.icon.html }), + divIcon: (options: { html: string }) => options, + }, + fitToPoints: vi.fn(), +})) +vi.mock('@/utils/heatLayer', () => ({ heatLayer: () => layer('heat', null) })) +vi.mock('@/utils/mapCarIcon', () => ({ + buildCarMarkerIcon: () => ({ html: 'car' }), + moveCarMarker: vi.fn(), +})) + +const lines = () => drawn.filter((d) => d.kind === 'line') +const markers = () => drawn.filter((d) => d.kind === 'marker').map((d) => d.options.html) +const heat = () => drawn.some((d) => d.kind === 'heat') + +function points(...speeds: (number | null)[]): TripPoint[] { + return speeds.map((speed, i) => ({ + recordedAt: `2026-10-19T07:0${i}:00Z`, + latitude: 52 + i / 100, + longitude: 5 + i / 100, + speed, + })) +} + +function trip(index: number, pts: TripPoint[]): Trip { + return { index, id: index + 1, points: pts } as Trip +} + +const twoTrips = () => [trip(0, points(30, 50, 70)), trip(1, points(80, 100))] + +function setup(overrides: Partial = {}) { + const map = { setView: vi.fn() } + const o = { + map: shallowRef(map as unknown as LeafletMap), + trips: ref(twoTrips()), + selectedIndex: ref(null), + activeIndex: ref(null), + status: ref(null), + activeCarLatLng: ref<[number, number] | null>(null), + selectedMatch: shallowRef(null), + limitColoursShown: ref(false), + heatPoints: ref<[number, number][]>([[52, 5]]), + onSelect: vi.fn<(index: number) => void>(), + takeSelectLatest: () => false, + ...overrides, + } + let api!: ReturnType + const wrapper = mount( + defineComponent({ + setup() { + api = useTripLayers(o) + return () => null + }, + }), + ) + return { o, map, api, wrapper } +} + +describe('useTripLayers', () => { + beforeEach(() => { + drawn.length = 0 + localStorage.clear() + setActivePinia(createPinia()) + vi.clearAllMocks() + // The redraws wait for the next frame; here that frame comes at once. + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + cb(0) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', () => {}) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('draws every trip over the heatmap and frames them all', () => { + const { api } = setup() + + api.drawInitial() + + expect(heat()).toBe(true) + expect(lines()).toHaveLength(2) + expect(vi.mocked(fitToPoints).mock.calls[0]![1]).toHaveLength(5) + }) + + it('leaves out a trip with a single fix, which makes no line', () => { + const { o, api } = setup() + o.trips.value = [...twoTrips(), trip(2, points(0))] + + api.drawInitial() + + expect(lines()).toHaveLength(2) + }) + + it('selects a trip when its line is clicked', () => { + const { o, api } = setup() + api.drawInitial() + + lines()[1]!.click!() + + expect(o.onSelect).toHaveBeenCalledWith(1) + }) + + it('outlines every line when the route outline is on', () => { + useMapSettingsStore().routeOutlineEnabled = true + const { api } = setup() + + api.drawInitial() + + expect(lines()).toHaveLength(4) + }) + + it('centres on the car once when there are no trips, and stays put after that', async () => { + const { o, map, api } = setup({ trips: ref([]) }) + api.drawInitial() + expect(map.setView).not.toHaveBeenCalled() + + o.status.value = { latitude: 52.1, longitude: 5.1 } as TelemetrySnapshot + await nextTick() + o.status.value = { latitude: 52.2, longitude: 5.2 } as TelemetrySnapshot + await nextTick() + + expect(map.setView).toHaveBeenCalledExactlyOnceWith([52.1, 5.1], 14, { animate: false }) + }) + + it('draws a selected trip on its own, from start to finish, without the heatmap', async () => { + const { o, api } = setup() + api.drawInitial() + vi.mocked(fitToPoints).mockClear() + + o.selectedIndex.value = 0 + await nextTick() + + expect(heat()).toBe(false) + expect(lines()).toHaveLength(1) + expect(markers()).toEqual([ + expect.stringContaining('trip-marker--start'), + expect.stringContaining('trip-marker--end'), + ]) + expect(vi.mocked(fitToPoints).mock.calls[0]![1]).toHaveLength(3) + }) + + it('ends the trip being driven at the car, which follows the live position', async () => { + const { o } = setup({ activeIndex: ref(1), activeCarLatLng: ref([52.5, 5.5]) }) + + o.selectedIndex.value = 1 + await nextTick() + expect(markers()).toEqual([expect.stringContaining('trip-marker--start'), 'car']) + + o.status.value = { latitude: 52.6, longitude: 5.6, heading: 90 } as TelemetrySnapshot + await nextTick() + expect(moveCarMarker).toHaveBeenCalledWith(expect.anything(), [52.6, 5.6], 90) + }) + + it('colours the selected trip by speed, one line per stretch between fixes', async () => { + useMapSettingsStore().speedOverlayEnabled = true + const { o } = setup() + + o.selectedIndex.value = 0 + await nextTick() + + expect(lines()).toHaveLength(2) + expect(new Set(lines().map((l) => l.options.color)).size).toBe(2) + }) + + it('colours the snapped trip against the speed limits, and switches the speed colours off for it', async () => { + const settings = useMapSettingsStore() + settings.speedOverlayEnabled = true + const { o } = setup() + o.selectedIndex.value = 0 + await nextTick() + + settings.speedLimitOverlayEnabled = true + o.limitColoursShown.value = true + o.selectedMatch.value = { + coordinates: [ + [52, 5], + [52.01, 5.01], + [52.02, 5.02], + ], + points: o.trips.value[0]!.points, + pointIndexes: [0, 1, 2], + matchedKm: 3, + speedLimits: [50, 30], + } + await flushPromises() + + expect(settings.speedOverlayEnabled).toBe(false) + expect(lines().map((l) => l.options.className)).toEqual([ + 'trip-limit-line trip-limit-line--under', + 'trip-limit-line trip-limit-line--over', + ]) + }) + + it('draws every trip again once the selection is dropped', async () => { + const { o } = setup() + o.selectedIndex.value = 0 + await nextTick() + + o.selectedIndex.value = null + await nextTick() + + expect(heat()).toBe(true) + expect(lines()).toHaveLength(2) + expect(markers()).toEqual([]) + }) + + it('shows the car rather than every trip when asked for it from a selected trip', async () => { + const { o, map, api } = setup() + o.selectedIndex.value = 0 + await nextTick() + vi.mocked(fitToPoints).mockClear() + + api.showCar([52.3, 4.9]) + await nextTick() + + expect(o.selectedIndex.value).toBeNull() + expect(map.setView).toHaveBeenCalledWith([52.3, 4.9], 14, { animate: false }) + expect(fitToPoints).not.toHaveBeenCalled() + }) + + it('selects the newest trip when the trips arrive, if the view was opened for it', async () => { + const onSelect = vi.fn<(index: number) => void>() + const { o } = setup({ trips: ref([]), onSelect, takeSelectLatest: () => true }) + + o.trips.value = twoTrips() + await flushPromises() + + expect(onSelect).toHaveBeenCalledWith(1) + }) +}) diff --git a/frontend/src/views/__tests__/DashboardView.spec.ts b/frontend/src/views/__tests__/DashboardView.spec.ts new file mode 100644 index 00000000..813dcc83 --- /dev/null +++ b/frontend/src/views/__tests__/DashboardView.spec.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createI18n } from 'vue-i18n' +import en from '@/locales/en.json' +import DashboardView from '../DashboardView.vue' +import { vehicleApi, type TelemetrySnapshot, type Trip, type Vehicle } from '@/services/vehicleApi' +import { rdwApi } from '@/services/rdwApi' +import { ApiError } from '@/services/apiCore' +import { useVehicleStore } from '@/stores/vehicle' + +vi.mock('@/services/vehicleApi', () => ({ + vehicleApi: { + list: vi.fn<() => Promise>(), + status: vi.fn<() => Promise>(), + config: vi.fn<() => Promise>>(), + latestTrip: vi.fn<() => Promise>(), + }, +})) + +// Loaded only in edit mode, as an async component, which a stub does not reach. +// __esModule, so defineAsyncComponent takes the default export as the component. +vi.mock('@/components/EditableCardGrid.vue', () => ({ + __esModule: true, + default: { template: '
    ' }, +})) + +vi.mock('@/services/rdwApi', async (importOriginal) => ({ + ...(await importOriginal()), + rdwApi: { get: vi.fn<(typeof import('@/services/rdwApi'))['rdwApi']['get']>() }, +})) + +// A car that has not reported yet: the API answers 204, which the client returns as undefined. +const noStatus = undefined as unknown as TelemetrySnapshot + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) + +// A dashboard left mounted keeps listening for the tab coming back into view. +enableAutoUnmount(afterEach) + +function car(vehicleType: Vehicle['vehicleType']): Vehicle { + return { id: 1, vin: 'FAKEVN00000000001', vehicleType } as Vehicle +} + +// What a parked electric car reports; fuel fields are null, as the API sends them. +const parked = { + recordedAt: '2026-10-19T10:00:00Z', + evSocPercent: 71, + fuelLevelPercent: null, + fuelRangeKm: null, + odometerKm: 24852, + batteryVoltage: 12.7, + isLocked: true, +} as unknown as TelemetrySnapshot + +async function mountView() { + const wrapper = mount(DashboardView, { + global: { + plugins: [i18n], + stubs: { + FontAwesomeIcon: true, + CarDiagram: true, + LocationMapWidget: true, + CardInfoWrap: { template: '
    ' }, + DashboardCardContent: { + props: ['cardId'], + template: '
    ', + }, + }, + }, + }) + await flushPromises() + return wrapper +} + +const cardIds = (wrapper: Awaited>) => + wrapper.findAll('.card').map((c) => c.attributes('data-id')) + +describe('DashboardView', () => { + beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + vi.clearAllMocks() + vi.mocked(vehicleApi.list).mockResolvedValue([car('bev')]) + vi.mocked(vehicleApi.status).mockResolvedValue(parked) + vi.mocked(vehicleApi.config).mockResolvedValue({}) + vi.mocked(vehicleApi.latestTrip).mockResolvedValue(undefined) + vi.mocked(rdwApi.get).mockRejectedValue(new ApiError(404, '/rdw', { code: 'rdw.disabled' })) + }) + + afterEach(() => { + vi.restoreAllMocks() + Reflect.deleteProperty(navigator, 'serviceWorker') + }) + + it('loads everything the cards show for the car', async () => { + await mountView() + + for (const call of [vehicleApi.status, vehicleApi.config, vehicleApi.latestTrip, rdwApi.get]) + expect(call).toHaveBeenCalledWith('FAKEVN00000000001') + }) + + it("shows the cards the car's drivetrain has, and leaves out the ones it has no use for", async () => { + const wrapper = await mountView() + + const shown = cardIds(wrapper) + expect(shown).toEqual(expect.arrayContaining(['evBattery', 'odometer', 'battery12v', 'doors'])) + expect(shown).not.toContain('fuelLevel') + expect(shown).not.toContain('rdw') + }) + + it('says there is no data yet when the car has reported nothing', async () => { + vi.mocked(vehicleApi.status).mockResolvedValue(noStatus) + + const wrapper = await mountView() + + expect(wrapper.text()).toContain(en.dashboard.noData) + expect(cardIds(wrapper)).toEqual([]) + }) + + it('says something went wrong when the status cannot be loaded', async () => { + vi.mocked(vehicleApi.status).mockRejectedValue(new ApiError(500, '/status')) + + const wrapper = await mountView() + + expect(wrapper.find('.error-state').text()).toContain(en.common.error) + }) + + it('reads the live status and the newest trip again when the tab comes back into view', async () => { + await mountView() + vi.clearAllMocks() + + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible') + document.dispatchEvent(new Event('visibilitychange')) + await flushPromises() + + expect(vehicleApi.status).toHaveBeenCalledOnce() + expect(vehicleApi.latestTrip).toHaveBeenCalledOnce() + expect(vehicleApi.config).not.toHaveBeenCalled() + }) + + it('reads the newest trip again once a trip ends', async () => { + await mountView() + vi.mocked(vehicleApi.latestTrip).mockClear() + + useVehicleStore().notifyTripCompleted() + await flushPromises() + + expect(vehicleApi.latestTrip).toHaveBeenCalledOnce() + }) + + it('reads the status again when a push notification arrives', async () => { + const serviceWorker = new EventTarget() + Object.defineProperty(navigator, 'serviceWorker', { value: serviceWorker, configurable: true }) + await mountView() + vi.mocked(vehicleApi.status).mockClear() + + serviceWorker.dispatchEvent( + new MessageEvent('message', { data: { type: 'NOTIFICATION_RECEIVED' } }), + ) + await flushPromises() + + expect(vehicleApi.status).toHaveBeenCalledOnce() + }) + + it('offers to reset the layout while it is being edited', async () => { + const wrapper = await mountView() + + await wrapper + .findAll('button') + .find((b) => b.text() === en.dashboard.editLayout)! + .trigger('click') + await vi.dynamicImportSettled() + await flushPromises() + + expect(wrapper.find('.card-grid').exists()).toBe(true) + expect(wrapper.text()).toContain(en.dashboard.resetLayout) + expect(wrapper.text()).toContain(en.dashboard.doneEditing) + }) +}) diff --git a/frontend/src/views/__tests__/MapView.spec.ts b/frontend/src/views/__tests__/MapView.spec.ts new file mode 100644 index 00000000..d656f0a5 --- /dev/null +++ b/frontend/src/views/__tests__/MapView.spec.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { defineComponent, h, ref } from 'vue' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createI18n } from 'vue-i18n' +import en from '@/locales/en.json' +import MapView from '../MapView.vue' +import { vehicleApi, type TelemetrySnapshot, type Trip, type Vehicle } from '@/services/vehicleApi' +import type { TripMatch } from '@/composables/useTripMatch' +import { useVehicleStore } from '@/stores/vehicle' +import { useUiSettingsStore } from '@/stores/settingsUi' + +vi.mock('@/services/vehicleApi', () => ({ + vehicleApi: { + list: vi.fn<() => Promise>(), + status: vi.fn<() => Promise>(), + config: vi.fn<() => Promise>>(), + trips: vi.fn<() => Promise>(), + }, +})) + +const route = { query: {} as Record } +const router = { replace: vi.fn<(to: { name: string }) => void>() } +vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => router })) + +// The map and its layers have their own suites (useTripLayers, usePoiLayers); here they only have +// to say what the view asked of them. +const tripLayers = { + drawInitial: vi.fn<() => void>(), + showCar: vi.fn<(latLng: [number, number]) => void>(), +} +vi.mock('@/composables/useTripLayers', () => ({ useTripLayers: () => tripLayers })) +vi.mock('@/composables/usePoiLayers', () => ({ + usePoiLayers: () => ({ + speedCamerasAvailable: ref(true), + availableFuelBrands: ref([]), + brandsLoading: ref(false), + poiLoading: ref(false), + loadLayers: vi.fn<(radiusKm?: number) => void>(), + }), +})) +vi.mock('@/composables/useBasemap', () => ({ useBasemap: () => undefined })) + +const match = ref(null) +const requestMatch = vi.fn<(trip: Trip | null) => void>() +vi.mock('@/composables/useTripMatch', () => ({ + useTripMatch: () => ({ + requestMatch, + matchFor: (trip: Trip | null) => (trip ? match.value : null), + snapEnabled: ref(true), + matching: ref(false), + }), +})) + +const requestPlaces = vi.fn<(points: unknown[], detail: string) => void>() +vi.mock('@/composables/useReverseGeocode', () => ({ + useReverseGeocode: () => ({ + requestPlaces, + placeFor: () => null, + placeNamesEnabled: ref(false), + placesResolving: ref(false), + }), +})) + +vi.mock('@vue-leaflet/vue-leaflet', () => { + const container = defineComponent({ + setup: + (_, { slots }) => + () => + h('div', slots.default?.()), + }) + return { + LMap: container, + LMarker: defineComponent({ + setup: + (_, { slots }) => + () => + h('div', { class: 'car-marker' }, slots.default?.()), + }), + LPopup: container, + } +}) + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) + +enableAutoUnmount(afterEach) + +const car = { id: 1, vin: 'FAKEVN00000000001', vehicleType: 'hev', model: 'Kia Niro' } as Vehicle +const parked = { latitude: 52.09, longitude: 5.12, currentJourneyDistance: 0 } as TelemetrySnapshot + +function trip(index: number): Trip { + const at = (minute: number) => `2026-10-${10 + index}T07:${minute}:00Z` + return { + index, + id: index + 1, + startedAt: at(0), + endedAt: at(30), + distanceKm: 10 + index, + pointCount: 2, + endLatitude: 52.1, + endLongitude: 5.1, + maxSpeedKmh: null, + avgMovingSpeedKmh: null, + movingSpeedSamples: 0, + points: [ + { recordedAt: at(0), latitude: 52, longitude: 5, speed: null }, + { recordedAt: at(30), latitude: 52.1, longitude: 5.1, speed: null }, + ], + } +} + +async function mountView() { + const wrapper = mount(MapView, { + global: { + plugins: [i18n], + stubs: { + FontAwesomeIcon: true, + MapFilterPanel: true, + MapLayerPanel: true, + SpeedLegend: true, + SpeedLimitLegend: true, + TripSidebar: { + props: ['rows', 'selectedIndex', 'activeIndex'], + emits: ['select'], + template: + '', + }, + }, + }, + }) + await flushPromises() + return wrapper +} + +const sidebar = (wrapper: Awaited>) => wrapper.find('.sidebar') +const findCar = (wrapper: Awaited>) => + wrapper.findAll('button').find((b) => b.text() === en.trips.findCar)! + +describe('MapView', () => { + beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + vi.clearAllMocks() + route.query = {} + match.value = null + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-19T12:00:00Z')) + vi.mocked(vehicleApi.list).mockResolvedValue([car]) + vi.mocked(vehicleApi.status).mockResolvedValue(parked) + vi.mocked(vehicleApi.config).mockResolvedValue({}) + vi.mocked(vehicleApi.trips).mockResolvedValue([trip(0), trip(1), trip(2)]) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("loads the car's position and the trips of the date filter's days", async () => { + useUiSettingsStore().filterDays = 7 + await mountView() + + expect(vehicleApi.status).toHaveBeenCalledWith(car.vin) + expect(vehicleApi.trips).toHaveBeenCalledWith(car.vin, '2026-10-12T12:00:00.000Z', undefined) + }) + + it('lists the trips newest first, and selects one on a tap and drops it on the next', async () => { + const wrapper = await mountView() + const rows = () => sidebar(wrapper).findAll('li') + + expect(rows().map((r) => r.text())).toEqual(['2', '1', '0']) + + await rows()[1]!.trigger('click') + expect(sidebar(wrapper).attributes('data-selected')).toBe('1') + // The car's marker makes way for the selected trip's own ends. + expect(wrapper.find('.car-marker').exists()).toBe(false) + + await rows()[1]!.trigger('click') + expect(sidebar(wrapper).attributes('data-selected')).toBeUndefined() + expect(wrapper.find('.car-marker').exists()).toBe(true) + }) + + it('marks the newest trip as the one being driven while the car is on its way', async () => { + vi.mocked(vehicleApi.status).mockResolvedValue({ ...parked, currentJourneyDistance: 4 }) + + const wrapper = await mountView() + + expect(sidebar(wrapper).attributes('data-active')).toBe('2') + }) + + it('looks up the street of a parked car, but not of one on its way', async () => { + await mountView() + expect(requestPlaces).toHaveBeenCalledWith([{ lat: 52.09, lng: 5.12 }], 'address') + + requestPlaces.mockClear() + setActivePinia(createPinia()) + vi.mocked(vehicleApi.status).mockResolvedValue({ ...parked, currentJourneyDistance: 4 }) + await mountView() + expect(requestPlaces).not.toHaveBeenCalledWith(expect.anything(), 'address') + }) + + it('loads the trips again and drops the selection when the date filter changes', async () => { + const wrapper = await mountView() + await sidebar(wrapper).findAll('li')[0]!.trigger('click') + vi.mocked(vehicleApi.trips).mockClear() + + useUiSettingsStore().filterDays = 90 + await flushPromises() + + expect(vehicleApi.trips).toHaveBeenCalledWith(car.vin, '2026-07-21T12:00:00.000Z', undefined) + expect(sidebar(wrapper).attributes('data-selected')).toBeUndefined() + }) + + it('loads the trips again once a trip ends, keeping the selection', async () => { + const wrapper = await mountView() + await sidebar(wrapper).findAll('li')[0]!.trigger('click') + vi.mocked(vehicleApi.trips).mockClear() + + useVehicleStore().notifyTripCompleted() + await flushPromises() + + expect(vehicleApi.trips).toHaveBeenCalledOnce() + expect(sidebar(wrapper).attributes('data-selected')).toBe('2') + }) + + it('shows the car on request, and offers it only while its position is known', async () => { + const wrapper = await mountView() + + await findCar(wrapper).trigger('click') + expect(tripLayers.showCar).toHaveBeenCalledWith([52.09, 5.12]) + + setActivePinia(createPinia()) + vi.mocked(vehicleApi.status).mockResolvedValue({ + currentJourneyDistance: 0, + } as TelemetrySnapshot) + const unplaced = await mountView() + expect(findCar(unplaced).attributes('disabled')).toBeDefined() + }) + + it('snaps the selected trip, and says how long it turned out to be', async () => { + const wrapper = await mountView() + match.value = { + coordinates: [], + points: [], + pointIndexes: [], + matchedKm: 12.4, + speedLimits: [], + } + + await sidebar(wrapper).findAll('li')[0]!.trigger('click') + + expect(requestMatch).toHaveBeenLastCalledWith(expect.objectContaining({ index: 2 })) + expect(wrapper.find('.map-pill').text()).toBe('Snapped · 12.4 km') + }) + + it('takes the request to open on the newest trip off the address once it has it', async () => { + route.query = { selectLatest: '1' } + + await mountView() + + expect(router.replace).toHaveBeenCalledWith({ name: 'map' }) + }) +}) diff --git a/frontend/src/views/__tests__/StatisticsView.spec.ts b/frontend/src/views/__tests__/StatisticsView.spec.ts new file mode 100644 index 00000000..c0a4fdb9 --- /dev/null +++ b/frontend/src/views/__tests__/StatisticsView.spec.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { defineComponent, h } from 'vue' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createI18n } from 'vue-i18n' +import en from '@/locales/en.json' +import StatisticsView from '../StatisticsView.vue' +import { + vehicleApi, + type DailyStat, + type TelemetrySnapshot, + type TripSummary, + type Vehicle, + type VehicleAggregateStats, +} from '@/services/vehicleApi' +import { ApiError } from '@/services/apiCore' +import { useVehicleStore } from '@/stores/vehicle' +import { useUiSettingsStore } from '@/stores/settingsUi' + +vi.mock('@/services/vehicleApi', () => ({ + vehicleApi: { + list: vi.fn<() => Promise>(), + status: vi.fn<() => Promise>(), + config: vi.fn<() => Promise>>(), + dailyStats: vi.fn<() => Promise>(), + tripSummaries: vi.fn<() => Promise>(), + stats: vi.fn<() => Promise>(), + }, +})) + +// The parking map is Leaflet's business; a marker per spot is all these tests look at. +vi.mock('@vue-leaflet/vue-leaflet', () => ({ + LMap: defineComponent({ + setup: + (_, { slots }) => + () => + h('div', slots.default?.()), + }), + LMarker: defineComponent({ setup: () => () => h('div', { class: 'marker' }) }), +})) +vi.mock('@/composables/useBasemap', () => ({ useBasemap: () => undefined })) + +// Loaded only in edit mode, as an async component; see DashboardView.spec.ts. +vi.mock('@/components/EditableCardGrid.vue', () => ({ + __esModule: true, + default: { template: '
    ' }, +})) + +// A car that has not reported yet: the API answers 204, which the client returns as undefined. +const noStatus = undefined as unknown as TelemetrySnapshot + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) +const insights = en.statistics.insights + +enableAutoUnmount(afterEach) + +function car(vehicleType: Vehicle['vehicleType']): Vehicle { + return { id: 1, vin: 'FAKEVN00000000001', vehicleType } as Vehicle +} + +function trip(overrides: Partial): TripSummary { + return { + index: 0, + id: 1, + startedAt: '2026-10-10T07:00:00Z', + endedAt: '2026-10-10T07:30:00Z', + distanceKm: 10, + pointCount: 50, + endLatitude: 52.09, + endLongitude: 5.12, + maxSpeedKmh: null, + avgMovingSpeedKmh: null, + movingSpeedSamples: 0, + ...overrides, + } +} + +// Three trips, two of them ending at the same place. +const trips = [ + trip({ index: 0, id: 1, distanceKm: 12 }), + trip({ index: 1, id: 2, distanceKm: 30, endLatitude: 52.37, endLongitude: 4.9 }), + trip({ index: 2, id: 3, distanceKm: 18 }), +] + +const parked = { recordedAt: '2026-10-19T10:00:00Z', batteryVoltage: 12.6 } as TelemetrySnapshot + +async function mountView() { + const wrapper = mount(StatisticsView, { + global: { + plugins: [i18n], + stubs: { + FontAwesomeIcon: true, + StatsPeriodFilter: true, + StatsChartCard: true, + StatsMonthTable: { template: '
    ' }, + CardInfoWrap: { template: '
    ' }, + }, + }, + }) + await flushPromises() + return wrapper +} + +// Each insight card shown, by its label, with its value and unit as the card's tooltip has them. +function shownInsights(wrapper: Awaited>) { + return Object.fromEntries( + wrapper + .findAll('.status-card') + .map((card) => [ + card.find('.status-card__label').text(), + card.find('.status-card__value').attributes('title'), + ]), + ) +} + +describe('StatisticsView', () => { + beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + vi.clearAllMocks() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-19T12:00:00Z')) + vi.mocked(vehicleApi.list).mockResolvedValue([car('hev')]) + vi.mocked(vehicleApi.status).mockResolvedValue(parked) + vi.mocked(vehicleApi.config).mockResolvedValue({}) + vi.mocked(vehicleApi.dailyStats).mockResolvedValue([]) + vi.mocked(vehicleApi.tripSummaries).mockResolvedValue(trips) + vi.mocked(vehicleApi.stats).mockResolvedValue({ + climateUsagePct: 25, + climateOnSnapshots: 1, + totalClimateSnapshots: 4, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // The last 30 days are the 30 before today, and today. + it('loads the last 30 days for the car', async () => { + await mountView() + + const [vin, from, to] = vi.mocked(vehicleApi.dailyStats).mock.calls[0]! + expect(vin).toBe('FAKEVN00000000001') + expect(from).toBe('2026-09-19') + expect(to).toBe('2026-10-19') + const [, fromIso, toIso] = vi.mocked(vehicleApi.tripSummaries).mock.calls[0]! + expect(vehicleApi.stats).toHaveBeenCalledWith('FAKEVN00000000001', fromIso, toIso) + expect(vehicleApi.status).toHaveBeenCalledWith('FAKEVN00000000001') + expect(vehicleApi.config).toHaveBeenCalledWith('FAKEVN00000000001') + }) + + it("sums the period's trips into its insights", async () => { + const wrapper = await mountView() + + const shown = shownInsights(wrapper) + expect(shown[insights.distanceInRange]).toBe('60.0 km') + expect(shown[insights.avgTripLength]).toBe('20.0 km') + expect(shown[insights.climateUsage]).toBe('25%') + expect(shown[insights.parkingLocations]).toBe('2') + expect(shown[insights.batteryVoltageTrend]).toBe('12.6 V') + }) + + it('leaves out the electric share for a car without a plug', async () => { + const wrapper = await mountView() + + expect(shownInsights(wrapper)).not.toHaveProperty(insights.electricShare) + }) + + it("shows a plug-in hybrid's electric share of today's driving", async () => { + vi.mocked(vehicleApi.list).mockResolvedValue([car('phev')]) + vi.mocked(vehicleApi.status).mockResolvedValue({ + ...parked, + mileageOfTheDay: 40, + mileageSinceLastCharge: 30, + } as TelemetrySnapshot) + + const wrapper = await mountView() + + expect(shownInsights(wrapper)[insights.electricShare]).toBe('75%') + }) + + it('shows where the car parked once the insight is opened', async () => { + const wrapper = await mountView() + + await wrapper.find('.status-card--clickable').trigger('click') + + // The modal is teleported to the page's body. + expect(document.body.querySelectorAll('.marker')).toHaveLength(2) + }) + + it('loads the new days when the period changes, with a month table for a long one', async () => { + const wrapper = await mountView() + expect(wrapper.find('.month-table').exists()).toBe(false) + vi.mocked(vehicleApi.dailyStats).mockClear() + + useUiSettingsStore().statsPeriod = { preset: 'last90', from: null, to: null } + await flushPromises() + + const [, from, to] = vi.mocked(vehicleApi.dailyStats).mock.calls[0]! + expect(from).toBe('2026-07-21') + expect(to).toBe('2026-10-19') + expect(wrapper.find('.month-table').exists()).toBe(true) + }) + + it('loads again once a trip ends', async () => { + await mountView() + vi.mocked(vehicleApi.tripSummaries).mockClear() + + useVehicleStore().notifyTripCompleted() + await flushPromises() + + expect(vehicleApi.tripSummaries).toHaveBeenCalledOnce() + }) + + it('says there is no data yet for a car that has reported nothing', async () => { + vi.mocked(vehicleApi.status).mockResolvedValue(noStatus) + vi.mocked(vehicleApi.tripSummaries).mockResolvedValue([]) + + const wrapper = await mountView() + + expect(wrapper.find('.empty-state').text()).toBe(en.dashboard.noData) + expect(wrapper.find('.status-card').exists()).toBe(false) + }) + + it('says something went wrong when the days cannot be loaded', async () => { + vi.mocked(vehicleApi.status).mockResolvedValue(noStatus) + vi.mocked(vehicleApi.tripSummaries).mockResolvedValue([]) + vi.mocked(vehicleApi.dailyStats).mockRejectedValue(new ApiError(500, '/daily-stats')) + + const wrapper = await mountView() + + expect(wrapper.find('.empty-state[role="alert"]').exists()).toBe(true) + }) +}) diff --git a/frontend/src/views/__tests__/TripLogView.spec.ts b/frontend/src/views/__tests__/TripLogView.spec.ts new file mode 100644 index 00000000..6d6ba03e --- /dev/null +++ b/frontend/src/views/__tests__/TripLogView.spec.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createI18n } from 'vue-i18n' +import en from '@/locales/en.json' +import TripLogView from '../TripLogView.vue' +import { tripLogApi, type TripLogEntry } from '@/services/tripLogApi' +import { commutePlacesApi } from '@/services/commutePlacesApi' +import { vehicleApi, type Vehicle } from '@/services/vehicleApi' +import { downloadCsv } from '@/utils/download' +import { useUiSettingsStore } from '@/stores/settingsUi' +import { logEntry } from '@/services/__tests__/tripLogFixtures' + +vi.mock('@/services/tripLogApi', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + tripLogApi: { + log: vi.fn(), + update: vi.fn(), + setPurpose: vi.fn(), + setHidden: vi.fn(), + mergeWithNext: vi.fn(), + resolvePlaces: vi.fn(), + }, + } +}) + +vi.mock('@/services/commutePlacesApi', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + commutePlacesApi: { + ...actual.commutePlacesApi, + list: vi.fn(), + }, + } +}) + +vi.mock('@/services/vehicleApi', () => ({ + vehicleApi: { list: vi.fn<() => Promise>() }, +})) + +vi.mock('@/utils/download', () => ({ downloadCsv: vi.fn() })) + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) +const car = { id: 1, vin: 'FAKEVN00000000001', vehicleType: 'hev' } as Vehicle + +// Oldest first, as the API answers; the page lists them newest first. +const trips: TripLogEntry[] = [ + logEntry({ id: 1, startedAt: '2026-10-01T07:00:00Z', purpose: 'business' }), + logEntry({ id: 2, startedAt: '2026-10-02T07:00:00Z', isHidden: true, notes: 'GPS glitch' }), + logEntry({ id: 3, startedAt: '2026-10-03T07:00:00Z', purpose: 'private' }), +] + +async function mountView() { + const wrapper = mount(TripLogView, { + global: { + plugins: [i18n], + stubs: { FontAwesomeIcon: true, CommutePlacesModal: true }, + }, + }) + await flushPromises() + return wrapper +} + +function listedIds(wrapper: Awaited>) { + return wrapper.findAll('.trip-log-item').map((item) => item.find('.trip-log-item__date').text()) +} + +function button(wrapper: Awaited>, text: string) { + return wrapper.findAll('button').filter((b) => b.text() === text) +} + +describe('TripLogView', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-19T12:00:00Z')) + vi.mocked(vehicleApi.list).mockResolvedValue([car]) + vi.mocked(commutePlacesApi.list).mockResolvedValue([]) + vi.mocked(tripLogApi.log).mockResolvedValue(trips) + vi.mocked(tripLogApi.resolvePlaces).mockResolvedValue({ + trips: [], + available: true, + hasMore: false, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("loads this month's trips for the car, newest first, without the hidden one", async () => { + const wrapper = await mountView() + + const [vin, from, to] = vi.mocked(tripLogApi.log).mock.calls[0]! + expect(vin).toBe(car.vin) + expect(new Date(from).getMonth()).toBe(9) + expect(new Date(to).getTime()).toBeGreaterThan(new Date(from).getTime()) + expect(listedIds(wrapper)).toEqual(['Sat, 3 Oct', 'Thu, 1 Oct']) + }) + + it('lists the hidden trips once asked to, and counts them on the switch', async () => { + const wrapper = await mountView() + const toggle = wrapper.find('.trip-log-hidden-toggle') + + expect(toggle.text()).toBe('Show hidden trip') + await toggle.find('input').setValue(true) + + expect(listedIds(wrapper)).toEqual(['Sat, 3 Oct', 'Fri, 2 Oct', 'Thu, 1 Oct']) + expect(wrapper.find('.trip-log-item--hidden').exists()).toBe(true) + }) + + it('leaves the hidden trip out of the totals and the export', async () => { + const wrapper = await mountView() + + await button(wrapper, 'Export CSV')[0]!.trigger('click') + + const csv = vi.mocked(downloadCsv).mock.calls[0]![1] + expect(csv).not.toContain('GPS glitch') + expect(csv.trim().split('\n')).toHaveLength(3) + expect(wrapper.find('.trip-log-totals').text()).toContain('Unclassified (0)') + }) + + it('joins a trip with the next one on a second tap, and offers none for the newest', async () => { + vi.mocked(tripLogApi.mergeWithNext).mockResolvedValue(logEntry({ id: 1, distanceKm: 80 })) + const wrapper = await mountView() + const items = wrapper.findAll('.trip-log-item') + + expect(items[0]!.text()).not.toContain('Join with next trip') + const join = () => items[1]!.findAll('button').find((b) => /join/i.test(b.text()))! + await join().trigger('click') + await join().trigger('click') + await flushPromises() + + expect(tripLogApi.mergeWithNext).toHaveBeenCalledWith(car.vin, 1) + }) + + it('hides a trip, which then leaves the list', async () => { + vi.mocked(tripLogApi.setHidden).mockResolvedValue({ ...trips[2]!, isHidden: true }) + const wrapper = await mountView() + + await button(wrapper, 'Hide')[0]!.trigger('click') + await flushPromises() + + expect(tripLogApi.setHidden).toHaveBeenCalledWith(car.vin, 3, true) + expect(listedIds(wrapper)).toEqual(['Thu, 1 Oct']) + expect(wrapper.find('.trip-log-hidden-toggle').text()).toBe('Show 2 hidden trips') + }) + + it('loads the whole year when the month is cleared', async () => { + const wrapper = await mountView() + + // The first option, "Whole year", has no month. + const month = wrapper.findAll('select')[1]! + ;(month.element as HTMLSelectElement).selectedIndex = 0 + await month.trigger('change') + await flushPromises() + + const [, from, to] = vi.mocked(tripLogApi.log).mock.lastCall! + expect(new Date(from).getMonth()).toBe(0) + expect(new Date(to).getFullYear()).toBe(2027) + }) + + it('asks for no addresses while place names are switched off', async () => { + setActivePinia(createPinia()) + useUiSettingsStore().placeNamesEnabled = false + + const wrapper = await mountView() + + expect(tripLogApi.resolvePlaces).not.toHaveBeenCalled() + expect(wrapper.text()).toContain(en.tripLog.placesOff) + }) +}) From 714cb0bf0974b48c5f461136043b6384a816a56d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:57:05 +0000 Subject: [PATCH 3/4] test(frontend): hand the new view and map layer specs their pinia Under a full test run the active pinia was sometimes another one by the time a test read its store, so the dashboard spec saw no reload after a trip ended about one run in four. Each mount now gets the test's pinia as a plugin, and every store the test reads names it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6DtW2eHhipteRopmy3cif --- .../__tests__/usePoiLayers.spec.ts | 19 +++++++++------ .../__tests__/useTripLayers.spec.ts | 16 +++++++++---- .../src/views/__tests__/DashboardView.spec.ts | 13 +++++++---- frontend/src/views/__tests__/MapView.spec.ts | 23 ++++++++++++------- .../views/__tests__/StatisticsView.spec.ts | 15 ++++++++---- .../src/views/__tests__/TripLogView.spec.ts | 16 +++++++++---- 6 files changed, 68 insertions(+), 34 deletions(-) diff --git a/frontend/src/composables/__tests__/usePoiLayers.spec.ts b/frontend/src/composables/__tests__/usePoiLayers.spec.ts index f6e754a0..2ef9dfca 100644 --- a/frontend/src/composables/__tests__/usePoiLayers.spec.ts +++ b/frontend/src/composables/__tests__/usePoiLayers.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' import { computed, defineComponent, nextTick, ref, shallowRef } from 'vue' import { flushPromises, mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' +import { createPinia, setActivePinia, type Pinia } from 'pinia' import { createI18n } from 'vue-i18n' import en from '@/locales/en.json' import type { LeafletMap } from '@/utils/leaflet' @@ -121,23 +121,28 @@ function setup(type: VehicleType, map: ReturnType | null = fakeM return () => null }, }), - { global: { plugins: [i18n] } }, + { global: { plugins: [i18n, pinia] } }, ) mapInstance.value = map as unknown as LeafletMap return { wrapper, result, vehicleType, mapInstance, map } } +// Handed to the mount and to every store the test reads, rather than left to the active pinia, +// which a busy test run has been seen to switch between a mount and its assertions. +let pinia: Pinia + describe('usePoiLayers', () => { beforeEach(() => { layers.length = 0 localStorage.clear() - setActivePinia(createPinia()) + pinia = createPinia() + setActivePinia(pinia) vi.clearAllMocks() vi.mocked(mapApi.poiBrands).mockResolvedValue([]) }) it('offers charging stations only to a car with a plug, and fuel stations only to one with a tank', () => { - const settings = useMapSettingsStore() + const settings = useMapSettingsStore(pinia) settings.chargingStationsEnabled = true settings.fuelStationsEnabled = true @@ -152,7 +157,7 @@ describe('usePoiLayers', () => { }) it('keeps the charging stations within the power range, and redraws when it changes', async () => { - const settings = useMapSettingsStore() + const settings = useMapSettingsStore(pinia) setup('bev') const keeps = (s: ChargingStation) => charging().options.include!(s) @@ -169,7 +174,7 @@ describe('usePoiLayers', () => { }) it('keeps the fuel stations that pass both the brand and the fuel type, and redraws when either changes', async () => { - const settings = useMapSettingsStore() + const settings = useMapSettingsStore(pinia) setup('hev') const keeps = (item: PoiItem) => fuel().options.include!(item) @@ -215,7 +220,7 @@ describe('usePoiLayers', () => { }) it('drops the speed camera layer once the server says it does not serve one', async () => { - useMapSettingsStore().speedCamerasEnabled = true + useMapSettingsStore(pinia).speedCamerasEnabled = true vi.mocked(mapApi.poi).mockResolvedValue({ items: [], hasMore: false, available: false }) const { result } = setup('hev') expect(speedCameras().options.enabled.value).toBe(true) diff --git a/frontend/src/composables/__tests__/useTripLayers.spec.ts b/frontend/src/composables/__tests__/useTripLayers.spec.ts index 50e5e16b..1e7971d8 100644 --- a/frontend/src/composables/__tests__/useTripLayers.spec.ts +++ b/frontend/src/composables/__tests__/useTripLayers.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { defineComponent, nextTick, ref, shallowRef } from 'vue' import { flushPromises, mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' +import { createPinia, setActivePinia, type Pinia } from 'pinia' import type { LeafletMap } from '@/utils/leaflet' import type { TelemetrySnapshot, Trip, TripPoint } from '@/services/vehicleApi' import type { TripMatch } from '../useTripMatch' @@ -97,15 +97,21 @@ function setup(overrides: Partial = {}) { return () => null }, }), + { global: { plugins: [pinia] } }, ) return { o, map, api, wrapper } } +// Handed to the mount and to every store the test reads, rather than left to the active pinia, +// which a busy test run has been seen to switch between a mount and its assertions. +let pinia: Pinia + describe('useTripLayers', () => { beforeEach(() => { drawn.length = 0 localStorage.clear() - setActivePinia(createPinia()) + pinia = createPinia() + setActivePinia(pinia) vi.clearAllMocks() // The redraws wait for the next frame; here that frame comes at once. vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { @@ -148,7 +154,7 @@ describe('useTripLayers', () => { }) it('outlines every line when the route outline is on', () => { - useMapSettingsStore().routeOutlineEnabled = true + useMapSettingsStore(pinia).routeOutlineEnabled = true const { api } = setup() api.drawInitial() @@ -199,7 +205,7 @@ describe('useTripLayers', () => { }) it('colours the selected trip by speed, one line per stretch between fixes', async () => { - useMapSettingsStore().speedOverlayEnabled = true + useMapSettingsStore(pinia).speedOverlayEnabled = true const { o } = setup() o.selectedIndex.value = 0 @@ -210,7 +216,7 @@ describe('useTripLayers', () => { }) it('colours the snapped trip against the speed limits, and switches the speed colours off for it', async () => { - const settings = useMapSettingsStore() + const settings = useMapSettingsStore(pinia) settings.speedOverlayEnabled = true const { o } = setup() o.selectedIndex.value = 0 diff --git a/frontend/src/views/__tests__/DashboardView.spec.ts b/frontend/src/views/__tests__/DashboardView.spec.ts index 813dcc83..bd46bfcf 100644 --- a/frontend/src/views/__tests__/DashboardView.spec.ts +++ b/frontend/src/views/__tests__/DashboardView.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' +import { createPinia, setActivePinia, type Pinia } from 'pinia' import { createI18n } from 'vue-i18n' import en from '@/locales/en.json' import DashboardView from '../DashboardView.vue' @@ -56,7 +56,7 @@ const parked = { async function mountView() { const wrapper = mount(DashboardView, { global: { - plugins: [i18n], + plugins: [i18n, pinia], stubs: { FontAwesomeIcon: true, CarDiagram: true, @@ -76,10 +76,15 @@ async function mountView() { const cardIds = (wrapper: Awaited>) => wrapper.findAll('.card').map((c) => c.attributes('data-id')) +// Handed to the mount and to every store the test reads, rather than left to the active pinia, +// which a busy test run has been seen to switch between a mount and its assertions. +let pinia: Pinia + describe('DashboardView', () => { beforeEach(() => { localStorage.clear() - setActivePinia(createPinia()) + pinia = createPinia() + setActivePinia(pinia) vi.clearAllMocks() vi.mocked(vehicleApi.list).mockResolvedValue([car('bev')]) vi.mocked(vehicleApi.status).mockResolvedValue(parked) @@ -143,7 +148,7 @@ describe('DashboardView', () => { await mountView() vi.mocked(vehicleApi.latestTrip).mockClear() - useVehicleStore().notifyTripCompleted() + useVehicleStore(pinia).notifyTripCompleted() await flushPromises() expect(vehicleApi.latestTrip).toHaveBeenCalledOnce() diff --git a/frontend/src/views/__tests__/MapView.spec.ts b/frontend/src/views/__tests__/MapView.spec.ts index d656f0a5..9c2832dc 100644 --- a/frontend/src/views/__tests__/MapView.spec.ts +++ b/frontend/src/views/__tests__/MapView.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { defineComponent, h, ref } from 'vue' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' +import { createPinia, setActivePinia, type Pinia } from 'pinia' import { createI18n } from 'vue-i18n' import en from '@/locales/en.json' import MapView from '../MapView.vue' @@ -112,7 +112,7 @@ function trip(index: number): Trip { async function mountView() { const wrapper = mount(MapView, { global: { - plugins: [i18n], + plugins: [i18n, pinia], stubs: { FontAwesomeIcon: true, MapFilterPanel: true, @@ -137,10 +137,15 @@ const sidebar = (wrapper: Awaited>) => wrapper.find const findCar = (wrapper: Awaited>) => wrapper.findAll('button').find((b) => b.text() === en.trips.findCar)! +// Handed to the mount and to every store the test reads, rather than left to the active pinia, +// which a busy test run has been seen to switch between a mount and its assertions. +let pinia: Pinia + describe('MapView', () => { beforeEach(() => { localStorage.clear() - setActivePinia(createPinia()) + pinia = createPinia() + setActivePinia(pinia) vi.clearAllMocks() route.query = {} match.value = null @@ -157,7 +162,7 @@ describe('MapView', () => { }) it("loads the car's position and the trips of the date filter's days", async () => { - useUiSettingsStore().filterDays = 7 + useUiSettingsStore(pinia).filterDays = 7 await mountView() expect(vehicleApi.status).toHaveBeenCalledWith(car.vin) @@ -193,7 +198,8 @@ describe('MapView', () => { expect(requestPlaces).toHaveBeenCalledWith([{ lat: 52.09, lng: 5.12 }], 'address') requestPlaces.mockClear() - setActivePinia(createPinia()) + pinia = createPinia() + setActivePinia(pinia) vi.mocked(vehicleApi.status).mockResolvedValue({ ...parked, currentJourneyDistance: 4 }) await mountView() expect(requestPlaces).not.toHaveBeenCalledWith(expect.anything(), 'address') @@ -204,7 +210,7 @@ describe('MapView', () => { await sidebar(wrapper).findAll('li')[0]!.trigger('click') vi.mocked(vehicleApi.trips).mockClear() - useUiSettingsStore().filterDays = 90 + useUiSettingsStore(pinia).filterDays = 90 await flushPromises() expect(vehicleApi.trips).toHaveBeenCalledWith(car.vin, '2026-07-21T12:00:00.000Z', undefined) @@ -216,7 +222,7 @@ describe('MapView', () => { await sidebar(wrapper).findAll('li')[0]!.trigger('click') vi.mocked(vehicleApi.trips).mockClear() - useVehicleStore().notifyTripCompleted() + useVehicleStore(pinia).notifyTripCompleted() await flushPromises() expect(vehicleApi.trips).toHaveBeenCalledOnce() @@ -229,7 +235,8 @@ describe('MapView', () => { await findCar(wrapper).trigger('click') expect(tripLayers.showCar).toHaveBeenCalledWith([52.09, 5.12]) - setActivePinia(createPinia()) + pinia = createPinia() + setActivePinia(pinia) vi.mocked(vehicleApi.status).mockResolvedValue({ currentJourneyDistance: 0, } as TelemetrySnapshot) diff --git a/frontend/src/views/__tests__/StatisticsView.spec.ts b/frontend/src/views/__tests__/StatisticsView.spec.ts index c0a4fdb9..e44a7bcd 100644 --- a/frontend/src/views/__tests__/StatisticsView.spec.ts +++ b/frontend/src/views/__tests__/StatisticsView.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { defineComponent, h } from 'vue' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' +import { createPinia, setActivePinia, type Pinia } from 'pinia' import { createI18n } from 'vue-i18n' import en from '@/locales/en.json' import StatisticsView from '../StatisticsView.vue' @@ -87,7 +87,7 @@ const parked = { recordedAt: '2026-10-19T10:00:00Z', batteryVoltage: 12.6 } as T async function mountView() { const wrapper = mount(StatisticsView, { global: { - plugins: [i18n], + plugins: [i18n, pinia], stubs: { FontAwesomeIcon: true, StatsPeriodFilter: true, @@ -113,10 +113,15 @@ function shownInsights(wrapper: Awaited>) { ) } +// Handed to the mount and to every store the test reads, rather than left to the active pinia, +// which a busy test run has been seen to switch between a mount and its assertions. +let pinia: Pinia + describe('StatisticsView', () => { beforeEach(() => { localStorage.clear() - setActivePinia(createPinia()) + pinia = createPinia() + setActivePinia(pinia) vi.clearAllMocks() vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(new Date('2026-10-19T12:00:00Z')) @@ -194,7 +199,7 @@ describe('StatisticsView', () => { expect(wrapper.find('.month-table').exists()).toBe(false) vi.mocked(vehicleApi.dailyStats).mockClear() - useUiSettingsStore().statsPeriod = { preset: 'last90', from: null, to: null } + useUiSettingsStore(pinia).statsPeriod = { preset: 'last90', from: null, to: null } await flushPromises() const [, from, to] = vi.mocked(vehicleApi.dailyStats).mock.calls[0]! @@ -207,7 +212,7 @@ describe('StatisticsView', () => { await mountView() vi.mocked(vehicleApi.tripSummaries).mockClear() - useVehicleStore().notifyTripCompleted() + useVehicleStore(pinia).notifyTripCompleted() await flushPromises() expect(vehicleApi.tripSummaries).toHaveBeenCalledOnce() diff --git a/frontend/src/views/__tests__/TripLogView.spec.ts b/frontend/src/views/__tests__/TripLogView.spec.ts index 6d6ba03e..f611862c 100644 --- a/frontend/src/views/__tests__/TripLogView.spec.ts +++ b/frontend/src/views/__tests__/TripLogView.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' +import { createPinia, setActivePinia, type Pinia } from 'pinia' import { createI18n } from 'vue-i18n' import en from '@/locales/en.json' import TripLogView from '../TripLogView.vue' @@ -56,7 +56,7 @@ const trips: TripLogEntry[] = [ async function mountView() { const wrapper = mount(TripLogView, { global: { - plugins: [i18n], + plugins: [i18n, pinia], stubs: { FontAwesomeIcon: true, CommutePlacesModal: true }, }, }) @@ -72,9 +72,14 @@ function button(wrapper: Awaited>, text: string) { return wrapper.findAll('button').filter((b) => b.text() === text) } +// Handed to the mount and to every store the test reads, rather than left to the active pinia, +// which a busy test run has been seen to switch between a mount and its assertions. +let pinia: Pinia + describe('TripLogView', () => { beforeEach(() => { - setActivePinia(createPinia()) + pinia = createPinia() + setActivePinia(pinia) vi.clearAllMocks() vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(new Date('2026-10-19T12:00:00Z')) @@ -165,8 +170,9 @@ describe('TripLogView', () => { }) it('asks for no addresses while place names are switched off', async () => { - setActivePinia(createPinia()) - useUiSettingsStore().placeNamesEnabled = false + pinia = createPinia() + setActivePinia(pinia) + useUiSettingsStore(pinia).placeNamesEnabled = false const wrapper = await mountView() From f6a22431baea50d7b2ac1a3b5aa742cd32c770bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:04:41 +0000 Subject: [PATCH 4/4] feat(map): an opt-in compact format for the map's trips GET .../trips?format=compact serves the same trips with their fixes as an encoded polyline (six decimals), the milliseconds from each fix to the next and the speeds: a month of driving is about half the download. The map asks for it and unpacks it in vehicleApi.trips, so nothing past the client changes. The plain format stays the default, for scripts reading it with an API token. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6DtW2eHhipteRopmy3cif --- documentation/ARCHITECTURE.md | 1 + .../src/services/__tests__/vehicleApi.spec.ts | 45 +++++++++++++- frontend/src/services/vehicleApi.ts | 30 ++++++++- .../Endpoints/TripEndpoints.cs | 14 +++-- src/GarageStack.Core/Models/TripDto.cs | 51 +++++++++++++++ .../AuthenticationFlowTests.cs | 28 ++++++++- src/GarageStack.Tests/TripDtoTests.cs | 62 +++++++++++++++++++ 7 files changed, 222 insertions(+), 9 deletions(-) diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md index bf3eba44..12f9ffb4 100644 --- a/documentation/ARCHITECTURE.md +++ b/documentation/ARCHITECTURE.md @@ -79,6 +79,7 @@ The gateway reports positions, not trips. `TripSegmenter` in Core cuts GPS fixes - **Storage.** A trip is saved together with its fixes (`Trip.PointsJson`), so showing it does not mean reading and re-cutting raw telemetry. A unique index on `(VehicleId, StartedAt)` turns any slip in the line into an error rather than a duplicate. - **Serving.** The Api's trips endpoint goes through `TripRepository`. It serves the saved trips from before the line, and cuts everything after the line live; the trip being driven always comes from that live part. The line is read first, and only saved trips from before it are served: otherwise a save landing between the two reads would show its trips twice. - **Summaries.** Every trip carries the figures read off its fixes (where it ended, top speed, average speed while moving and how many readings that average covers), so a caller that only needs those asks for `?points=false` and gets no fixes at all. The statistics page works from these. `GET .../trips/latest` answers with the newest trip alone, the one being driven if there is one, and is what the dashboard asks for. +- **Compact fixes.** The map asks for `?format=compact` (`CompactTripDto`): the same trips with their fixes as an encoded polyline at six decimals (`PolylineCodec`, about 10 cm), the milliseconds from each fix to the next, and the speeds. A month of driving is about half the download of an object per fix, and nothing is rounded away that the map uses. `vehicleApi.trips` unpacks it into the plain shape, so nothing past the client sees the difference. It is opt-in, so a script reading the plain format with an API token is not affected. The frontend sees one list and can tell the two kinds apart only by `id`, which is null for a trip that has not been saved yet. diff --git a/frontend/src/services/__tests__/vehicleApi.spec.ts b/frontend/src/services/__tests__/vehicleApi.spec.ts index cabeb83b..51631f29 100644 --- a/frontend/src/services/__tests__/vehicleApi.spec.ts +++ b/frontend/src/services/__tests__/vehicleApi.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { vehicleApi } from '@/services/vehicleApi' +import { vehicleApi, type CompactTrip } from '@/services/vehicleApi' import { makeResponse, type FetchSpy } from './fetchStub' describe('vehicleApi', () => { @@ -44,6 +44,49 @@ describe('vehicleApi', () => { expect(url).toContain('points=false') }) + it('trips() asks for the compact format and unpacks each trip into its fixes', async () => { + const compact: CompactTrip = { + index: 0, + id: 7, + startedAt: '2026-10-19T07:00:00Z', + endedAt: '2026-10-19T07:01:25Z', + distanceKm: 0.4, + pointCount: 2, + endLatitude: 52.092346, + endLongitude: 5.119877, + maxSpeedKmh: 48.5, + avgMovingSpeedKmh: 48.5, + movingSpeedSamples: 1, + // 52.091235,5.121235 then 52.092346,5.119877 at six decimals, encoded outside this code. + line: 'eukjbBelqwHmdAzsA', + intervalsMs: [2000, 40500], + speeds: [null, 48.5], + } + const fetchSpy = vi.fn().mockResolvedValue(makeResponse(200, [compact])) + vi.stubGlobal('fetch', fetchSpy) + + const [trip] = await vehicleApi.trips('VIN1', '2026-10-01') + + const [url] = fetchSpy.mock.calls[0]! + expect(url).toContain('format=compact') + expect(trip!.points).toEqual([ + { + recordedAt: '2026-10-19T07:00:02.000Z', + latitude: 52.091235, + longitude: 5.121235, + speed: null, + }, + { + recordedAt: '2026-10-19T07:00:42.500Z', + latitude: 52.092346, + longitude: 5.119877, + speed: 48.5, + }, + ]) + expect(trip).not.toHaveProperty('line') + expect(trip!.maxSpeedKmh).toBe(48.5) + }) + it('latestTrip() returns undefined when the vehicle has no trip', async () => { const fetchSpy = vi.fn().mockResolvedValue(makeResponse(204)) vi.stubGlobal('fetch', fetchSpy) diff --git a/frontend/src/services/vehicleApi.ts b/frontend/src/services/vehicleApi.ts index f21d2856..880e5691 100644 --- a/frontend/src/services/vehicleApi.ts +++ b/frontend/src/services/vehicleApi.ts @@ -1,4 +1,5 @@ import { request, send, buildQuery } from '@/services/apiCore' +import { decodePolyline } from '@/utils/polyline' /** Drivetrains the API can report, detected from the vehicle's hardware version. */ export type VehicleType = 'hev' | 'phev' | 'bev' | 'unknown' @@ -168,6 +169,27 @@ export interface Trip extends TripSummary { points: TripPoint[] } +/** + * A trip as `format=compact` serves it: its fixes as an encoded polyline, the milliseconds from + * each fix to the next (from the trip's start for the first) and the speeds, about half the size + * of a JSON object per fix. + */ +export interface CompactTrip extends TripSummary { + line: string + intervalsMs: number[] + speeds: (number | null)[] +} + +/** The compact trip as the plain one it was packed from. */ +export function unpackTrip({ line, intervalsMs, speeds, ...summary }: CompactTrip): Trip { + let at = Date.parse(summary.startedAt) + const points = decodePolyline(line).map(([latitude, longitude], i) => { + at += intervalsMs[i] ?? 0 + return { recordedAt: new Date(at).toISOString(), latitude, longitude, speed: speeds[i] ?? null } + }) + return { ...summary, points } +} + /** * The gateway's answer to a command, pushed over SignalR once the car carried it out or refused. * `command` is the name it was sent under (e.g. "lock"); `detail` is the gateway's reason for a @@ -223,9 +245,11 @@ export const vehicleApi = { /** A month of daily tyre pressures, in the browser's days, and each tyre's trend against the others. */ tyreHistory: (vin: string, timeZone: string) => request(`/api/vehicles/${vin}/tyres${buildQuery({ timeZone })}`), - trips: (vin: string, from?: string, to?: string) => { - const query = buildQuery({ from, to }) - return request(`/api/vehicles/${vin}/trips${query}`) + // Compact: up to 90 days of fixes, which as an object each would be twice the download. + trips: async (vin: string, from?: string, to?: string) => { + const query = buildQuery({ from, to, format: 'compact' }) + const trips = await request(`/api/vehicles/${vin}/trips${query}`) + return trips.map(unpackTrip) }, /** The same period of trips without their fixes, for pages that only need the figures. */ tripSummaries: (vin: string, from?: string, to?: string) => { diff --git a/src/GarageStack.Api/Endpoints/TripEndpoints.cs b/src/GarageStack.Api/Endpoints/TripEndpoints.cs index 0c6e75a6..de4c3cb2 100644 --- a/src/GarageStack.Api/Endpoints/TripEndpoints.cs +++ b/src/GarageStack.Api/Endpoints/TripEndpoints.cs @@ -23,10 +23,15 @@ public static IEndpointRouteBuilder MapTripEndpoints(this IEndpointRouteBuilder DateTimeOffset? from, DateTimeOffset? to, bool? points, + string? format, CancellationToken ct) => { var vehicle = httpContext.ResolvedVehicle(); + var compact = format == "compact"; + if (format is not null && !compact) + return ApiProblems.BadRequest("trip.unknownFormat", "format must be compact, or left out for the plain format"); + // Without their fixes a trip is one small row, so the statistics page can ask for years; // with them (the map) a period stays at most 90 days. var summariesOnly = points == false; @@ -34,11 +39,12 @@ public static IEndpointRouteBuilder MapTripEndpoints(this IEndpointRouteBuilder summariesOnly ? DateRange.LongestStatisticsSpan : TimeSpan.FromDays(90), out var start, out var end); if (rangeError is not null) return rangeError; - return summariesOnly - ? Results.Ok(await trips.GetSummariesAsync(vehicle.Id, start, end, ct)) - : Results.Ok(await trips.GetTripsAsync(vehicle.Id, start, end, ct)); + if (summariesOnly) return Results.Ok(await trips.GetSummariesAsync(vehicle.Id, start, end, ct)); + + var found = await trips.GetTripsAsync(vehicle.Id, start, end, ct); + return compact ? Results.Ok(found.Select(t => t.ToCompact()).ToList()) : Results.Ok(found); }) - .WithSummary("Get trip history (saved trips, then the ones not saved yet, including the trip being driven); points=false leaves out the fixes"); + .WithSummary("Get trip history (saved trips, then the ones not saved yet, including the trip being driven); points=false leaves out the fixes, format=compact packs them into a polyline and two arrays"); // The dashboard shows one trip, so it asks for that one rather than a period of them. group.MapGet("/latest", async (HttpContext httpContext, ITripRepository trips, CancellationToken ct) => diff --git a/src/GarageStack.Core/Models/TripDto.cs b/src/GarageStack.Core/Models/TripDto.cs index fcbb2707..55b51b43 100644 --- a/src/GarageStack.Core/Models/TripDto.cs +++ b/src/GarageStack.Core/Models/TripDto.cs @@ -1,3 +1,5 @@ +using GarageStack.Core.Helpers; + namespace GarageStack.Core.Models; public record TripPoint(DateTime RecordedAt, double Latitude, double Longitude, double? Speed); @@ -34,6 +36,28 @@ public record TripDto(int Index, DateTime StartedAt, DateTime EndedAt, double Di public TripSummaryDto ToSummary() => new( Index, Id, StartedAt, EndedAt, DistanceKm, PointCount, EndLatitude, EndLongitude, MaxSpeedKmh, AvgMovingSpeedKmh, MovingSpeedSamples); + + /// The same trip with its fixes packed into three arrays; see . + public CompactTripDto ToCompact() + { + // Between rounded offsets, not rounded after subtracting, as the polyline does, so the + // running total stays within half a millisecond of every fix instead of drifting. + var intervals = new long[Points.Count]; + long previous = 0; + for (var i = 0; i < Points.Count; i++) + { + var offset = (long)Math.Round((Points[i].RecordedAt - StartedAt).TotalMilliseconds); + intervals[i] = offset - previous; + previous = offset; + } + + return new( + Index, Id, StartedAt, EndedAt, DistanceKm, PointCount, + EndLatitude, EndLongitude, MaxSpeedKmh, AvgMovingSpeedKmh, MovingSpeedSamples, + PolylineCodec.Encode([.. Points.Select(p => new GeoCoordinate(p.Latitude, p.Longitude))]), + intervals, + [.. Points.Select(p => p.Speed)]); + } } /// @@ -52,3 +76,30 @@ public record TripSummaryDto( double? MaxSpeedKmh, double? AvgMovingSpeedKmh, int MovingSpeedSamples); + +/// +/// A trip as serves it, with its fixes as three arrays instead of an object +/// each: about half the size gzipped, for a map that shows up to 90 days of them. Opt-in +/// (?format=compact), so a script reading the plain format is not affected. +/// +/// Fix i is at 's vertex i (an encoded polyline at +/// decimals, about 10 cm) and reported +/// [i]. [i] is the milliseconds since the fix before +/// it, or since for the first, so a fix's time is their running total. +/// +/// +public record CompactTripDto( + int Index, + long? Id, + DateTime StartedAt, + DateTime EndedAt, + double DistanceKm, + int PointCount, + double? EndLatitude, + double? EndLongitude, + double? MaxSpeedKmh, + double? AvgMovingSpeedKmh, + int MovingSpeedSamples, + string Line, + IReadOnlyList IntervalsMs, + IReadOnlyList Speeds); diff --git a/src/GarageStack.Tests/AuthenticationFlowTests.cs b/src/GarageStack.Tests/AuthenticationFlowTests.cs index c4b95b32..296e3c44 100644 --- a/src/GarageStack.Tests/AuthenticationFlowTests.cs +++ b/src/GarageStack.Tests/AuthenticationFlowTests.cs @@ -6,6 +6,7 @@ using GarageStack.Api.Endpoints; using GarageStack.Api.Services; using GarageStack.Core.Configuration; +using GarageStack.Core.Helpers; using GarageStack.Core.Models; using GarageStack.Data.Demo; using Microsoft.AspNetCore.Authentication.OpenIdConnect; @@ -386,7 +387,8 @@ async Task EntryAsync(long id) => } // Through the whole stack, on the demo's trips: hiding needs a yes or no, hides the trip from - // the map and keeps it in the log, and a merge leaves one trip where there were two. + // the map and keeps it in the log, the compact format serves the same trips, and a merge leaves + // one trip where there were two. [Fact] public async Task Trips_CanBeHiddenAndShownAgain_AndJoinedWithTheNextOne() { @@ -408,6 +410,8 @@ string Url(string path) => $"/api/vehicles/{vin}/trips{path}?from={DateTimeOffse var hidden = await (await client.PutAsJsonAsync($"/api/vehicles/{vin}/trips/{first.Id}/hidden", new { hidden = true }, ct)) .Content.ReadFromJsonAsync(ct); var mapWhileHidden = (await client.GetFromJsonAsync>(Url(""), ct))!; + var compactWhileHidden = (await client.GetFromJsonAsync>($"{Url("")}&format=compact", ct))!; + var unknownFormat = await client.GetAsync($"{Url("")}&format=gpx", ct); await client.PutAsJsonAsync($"/api/vehicles/{vin}/trips/{first.Id}/hidden", new { hidden = false }, ct); var merge = await client.PostAsync($"/api/vehicles/{vin}/trips/{first.Id}/merge-next", null, ct); var merged = await merge.Content.ReadFromJsonAsync(ct); @@ -418,6 +422,28 @@ string Url(string path) => $"/api/vehicles/{vin}/trips{path}?from={DateTimeOffse Assert.True(hidden!.IsHidden); // The trip being driven has no id yet. Assert.DoesNotContain(mapWhileHidden, t => t.GetProperty("id") is { ValueKind: JsonValueKind.Number } id && id.GetInt64() == first.Id); + // The compact format is the same trips, the fixes packed. + Assert.Equal(mapWhileHidden.Select(t => t.GetProperty("startedAt").GetDateTime()), compactWhileHidden.Select(t => t.GetProperty("startedAt").GetDateTime())); + Assert.All(compactWhileHidden.Zip(mapWhileHidden), pair => + { + var (compactTrip, plainTrip) = pair; + var fixes = plainTrip.GetProperty("points").EnumerateArray().ToList(); + var line = PolylineCodec.Decode(compactTrip.GetProperty("line").GetString()); + var intervals = compactTrip.GetProperty("intervalsMs").EnumerateArray().Select(i => i.GetInt64()).ToList(); + var speeds = compactTrip.GetProperty("speeds").EnumerateArray().ToList(); + Assert.Equal(fixes.Count, line.Count); + var at = compactTrip.GetProperty("startedAt").GetDateTime(); + for (var i = 0; i < fixes.Count; i++) + { + at = at.AddMilliseconds(intervals[i]); + Assert.Equal(fixes[i].GetProperty("recordedAt").GetDateTime(), at, TimeSpan.FromMilliseconds(1)); + Assert.Equal(fixes[i].GetProperty("latitude").GetDouble(), line[i].Lat, 6); + Assert.Equal(fixes[i].GetProperty("longitude").GetDouble(), line[i].Lng, 6); + Assert.Equal(fixes[i].GetProperty("speed").ToString(), speeds[i].ToString()); + } + }); + Assert.Equal(HttpStatusCode.BadRequest, unknownFormat.StatusCode); + Assert.Equal("trip.unknownFormat", await ProblemCodeAsync(unknownFormat, ct)); Assert.Equal(HttpStatusCode.OK, merge.StatusCode); Assert.Equal((first.StartedAt, second.EndedAt), (merged!.StartedAt, merged.EndedAt)); Assert.Equal(log.Count - 1, logAfter.Count); diff --git a/src/GarageStack.Tests/TripDtoTests.cs b/src/GarageStack.Tests/TripDtoTests.cs index d0cf684f..a2b440bc 100644 --- a/src/GarageStack.Tests/TripDtoTests.cs +++ b/src/GarageStack.Tests/TripDtoTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using GarageStack.Core.Helpers; using GarageStack.Core.Models; namespace GarageStack.Tests; @@ -70,4 +71,65 @@ public void FullTrip_ServesTheSummaryBesideTheFixes() Assert.Equal(2, root.GetProperty("points").GetArrayLength()); Assert.Equal(50, root.GetProperty("maxSpeedKmh").GetDouble()); } + + [Fact] + public void ToCompact_PacksTheFixes_SoTheyUnpackAsTheyWere() + { + var started = T0.AddSeconds(-2); + TripPoint[] points = + [ + new(T0, 52.0912345, 5.1212345, null), + new(T0.AddSeconds(40.5), 52.0923456, 5.1198765, 48.5), + new(T0.AddSeconds(85), 52.0934567, 5.1187654, 0), + ]; + var trip = new TripDto(3, started, T0.AddSeconds(85), 0.4, 3, points, Id: 7); + + var compact = trip.ToCompact(); + + var line = PolylineCodec.Decode(compact.Line); + var at = started; + for (var i = 0; i < points.Length; i++) + { + at = at.AddMilliseconds(compact.IntervalsMs[i]); + Assert.Equal(points[i].RecordedAt, at); + Assert.Equal(points[i].Latitude, line[i].Lat, 6); + Assert.Equal(points[i].Longitude, line[i].Lng, 6); + Assert.Equal(points[i].Speed, compact.Speeds[i]); + } + Assert.Equal([2000L, 40500L, 44500L], compact.IntervalsMs); + Assert.Equal(trip.ToSummary(), new TripSummaryDto( + compact.Index, compact.Id, compact.StartedAt, compact.EndedAt, compact.DistanceKm, compact.PointCount, + compact.EndLatitude, compact.EndLongitude, compact.MaxSpeedKmh, compact.AvgMovingSpeedKmh, compact.MovingSpeedSamples)); + } + + [Fact] + public void ToCompact_TimesBetweenMilliseconds_StayWithinHalfOfOne_HoweverLongTheTrip() + { + // 40.6 ms apart: rounding each interval on its own would gain 0.4 ms a fix. + var points = Enumerable.Range(0, 200) + .Select(i => new TripPoint(T0.AddTicks(i * 406_000L), 52.0, 5.0, null)) + .ToList(); + var trip = new TripDto(0, T0, points[^1].RecordedAt, 1, points.Count, points); + + var compact = trip.ToCompact(); + + var at = T0; + for (var i = 0; i < points.Count; i++) + { + at = at.AddMilliseconds(compact.IntervalsMs[i]); + Assert.Equal(points[i].RecordedAt, at, TimeSpan.FromMilliseconds(0.5)); + } + } + + [Fact] + public void ToCompact_ServesNoPointsArray() + { + using var json = JsonDocument.Parse(JsonSerializer.Serialize(Trip(30, 50).ToCompact(), JsonSerializerOptions.Web)); + var root = json.RootElement; + + Assert.False(root.TryGetProperty("points", out _)); + Assert.Equal(JsonValueKind.String, root.GetProperty("line").ValueKind); + Assert.Equal(2, root.GetProperty("intervalsMs").GetArrayLength()); + Assert.Equal(2, root.GetProperty("speeds").GetArrayLength()); + } }