Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion documentation/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
275 changes: 275 additions & 0 deletions frontend/src/composables/__tests__/usePoiLayers.spec.ts
Original file line number Diff line number Diff line change
@@ -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<never>
items: Map<string, unknown>
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<typeof import('../poiTileLayer')>()).escapeHtml,
createMarker: (
_lat: number,
_lng: number,
className: string,
_glyph: string,
popup: () => string,
) => ({
className,
popup,
}),
createTileLayer: (options: TileLayerOptions<never>) => {
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<typeof mapApi.chargingStations>(),
poi: vi.fn<typeof mapApi.poi>(),
poiBrands: vi.fn<typeof mapApi.poiBrands>(),
},
}))

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<ChargingStation> }
const fuel = () => layers[1]! as unknown as FakeLayer & { options: TileLayerOptions<PoiItem> }
const speedCameras = () =>
layers[3]! as unknown as FakeLayer & { options: TileLayerOptions<PoiItem> }

function station(kw: (number | null)[], overrides: Partial<ChargingStation> = {}): 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<string, string>, overrides: Partial<PoiItem> = {}): PoiItem {
return {
externalId: 'node/1',
poiType: 'fuel',
latitude: 52,
longitude: 5,
name: 'Tankstation',
tags,
...overrides,
} as PoiItem
}

function fakeMap() {
const handlers = new Map<string, () => 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<typeof fakeMap> | null = fakeMap()) {
const vehicleType = ref<VehicleType>(type)
const mapInstance = shallowRef<LeafletMap | null>(null)
let result!: ReturnType<typeof usePoiLayers>
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: '<b>Laadplein</b>',
numberOfPoints: 3,
town: 'Utrecht',
addressLine: 'Stationsplein 1',
}),
) as unknown as { popup: () => string }

const html = marker.popup()
expect(html).toContain('&lt;b&gt;Laadplein&lt;/b&gt;')
expect(html).toContain('<li>Type 2 · 22 kW ×2</li>')
expect(html).toContain('<li>Type 2 · 50 kW</li>')
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()
})
})
Loading
Loading