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..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. @@ -329,7 +330,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/frontend/src/composables/__tests__/usePoiLayers.spec.ts b/frontend/src/composables/__tests__/usePoiLayers.spec.ts new file mode 100644 index 00000000..2ef9dfca --- /dev/null +++ b/frontend/src/composables/__tests__/usePoiLayers.spec.ts @@ -0,0 +1,275 @@ +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, type Pinia } 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, 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() + 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(pinia) + 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(pinia) + 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(pinia) + 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(pinia).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..1e7971d8 --- /dev/null +++ b/frontend/src/composables/__tests__/useTripLayers.spec.ts @@ -0,0 +1,283 @@ +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, type Pinia } 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 + }, + }), + { 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() + pinia = createPinia() + setActivePinia(pinia) + 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(pinia).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(pinia).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(pinia) + 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/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/frontend/src/views/__tests__/DashboardView.spec.ts b/frontend/src/views/__tests__/DashboardView.spec.ts new file mode 100644 index 00000000..bd46bfcf --- /dev/null +++ b/frontend/src/views/__tests__/DashboardView.spec.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } 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, pinia], + 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')) + +// 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() + pinia = createPinia() + setActivePinia(pinia) + 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(pinia).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..9c2832dc --- /dev/null +++ b/frontend/src/views/__tests__/MapView.spec.ts @@ -0,0 +1,270 @@ +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, type Pinia } 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, pinia], + 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)! + +// 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() + pinia = createPinia() + setActivePinia(pinia) + 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(pinia).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() + pinia = createPinia() + setActivePinia(pinia) + 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(pinia).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(pinia).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]) + + pinia = createPinia() + setActivePinia(pinia) + 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..e44a7bcd --- /dev/null +++ b/frontend/src/views/__tests__/StatisticsView.spec.ts @@ -0,0 +1,240 @@ +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, type Pinia } 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, pinia], + 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'), + ]), + ) +} + +// 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() + pinia = createPinia() + setActivePinia(pinia) + 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(pinia).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(pinia).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..f611862c --- /dev/null +++ b/frontend/src/views/__tests__/TripLogView.spec.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } 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, pinia], + 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) +} + +// 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(() => { + pinia = createPinia() + setActivePinia(pinia) + 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 () => { + pinia = createPinia() + setActivePinia(pinia) + useUiSettingsStore(pinia).placeNamesEnabled = false + + const wrapper = await mountView() + + expect(tripLogApi.resolvePlaces).not.toHaveBeenCalled() + expect(wrapper.text()).toContain(en.tripLog.placesOff) + }) +}) 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/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()); + } +} 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()); + } }