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
550 changes: 550 additions & 0 deletions scripts/generate.ts

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions src/__tests__/data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

import type { Location, SafetyEvent, Vehicle } from '../core/model'

interface Truth {
vehicles: Vehicle[]
locations: Location[]
events: SafetyEvent[]
}

const here = dirname(fileURLToPath(import.meta.url))
const dataDir = resolve(here, '../data')

function readJson<T>(relPath: string): T {
return JSON.parse(readFileSync(resolve(dataDir, relPath), 'utf8')) as T
}

const truth = readJson<Truth>('truth.json')
// Raw ping shapes differ per provider; we only need their counts here.
const northwind = readJson<unknown[]>('raw/northwind.json')
const haulix = readJson<unknown[]>('raw/haulix.json')
const tracpoint = readJson<unknown[]>('raw/tracpoint.json')

// Rough Greater Toronto Area bounding box.
const GTA = { minLat: 43.2, maxLat: 44.2, minLng: -80.2, maxLng: -78.8 }

describe('canonical truth data', () => {
it('has vehicles and locations', () => {
expect(truth.vehicles.length).toBeGreaterThan(0)
expect(truth.locations.length).toBeGreaterThan(0)
})

it('gives every vehicle a strictly increasing timestamp series', () => {
for (const vehicle of truth.vehicles) {
const times = truth.locations
.filter((l) => l.vehicleId === vehicle.id)
.map((l) => Date.parse(l.timestamp))
expect(times.length).toBeGreaterThan(0)
for (let i = 1; i < times.length; i++) {
expect(times[i]).toBeGreaterThan(times[i - 1])
}
}
})

it('keeps all coordinates within rough GTA bounds', () => {
for (const loc of truth.locations) {
expect(loc.lat).toBeGreaterThanOrEqual(GTA.minLat)
expect(loc.lat).toBeLessThanOrEqual(GTA.maxLat)
expect(loc.lng).toBeGreaterThanOrEqual(GTA.minLng)
expect(loc.lng).toBeLessThanOrEqual(GTA.maxLng)
}
})

it('never reports a negative speed', () => {
for (const loc of truth.locations) {
expect(loc.speedKmh).toBeGreaterThanOrEqual(0)
}
})
})

describe('canonical safety events', () => {
it('has at least one event', () => {
expect(truth.events.length).toBeGreaterThan(0)
})

it('places every event within its vehicle location time span', () => {
const span = new Map<string, { min: number; max: number }>()
for (const loc of truth.locations) {
Comment on lines +69 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Remove duplicate span declaration (compile-time blocker).

span is declared twice in the same scope, which causes a TypeScript compile error and prevents the test suite from running.

Suggested fix
   it('places every event within its vehicle location time span', () => {
-    const span = new Map<string, { min: number; max: number }>()
     const span = new Map<string, { min: number; max: number }>()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/data.test.ts` around lines 69 - 70, The variable `span` is
declared twice in the same scope within the test file, causing a TypeScript
compile error. Remove the duplicate declaration of the Map variable `span` that
appears at lines 69-70 in the data.test.ts file, keeping only one declaration of
the span variable with the type Map<string, { min: number; max: number }>.

const t = Date.parse(loc.timestamp)
const cur = span.get(loc.vehicleId)
if (cur === undefined) {
span.set(loc.vehicleId, { min: t, max: t })
} else {
if (t < cur.min) cur.min = t
if (t > cur.max) cur.max = t
}
}
for (const event of truth.events) {
const bounds = span.get(event.vehicleId)
expect(bounds).toBeDefined()
const t = Date.parse(event.timestamp)
expect(t).toBeGreaterThanOrEqual(bounds!.min)
expect(t).toBeLessThanOrEqual(bounds!.max)
}
})

it('only references vehicle ids that exist', () => {
const ids = new Set(truth.vehicles.map((v) => v.id))
for (const event of truth.events) {
expect(ids.has(event.vehicleId)).toBe(true)
}
})
})

describe('raw provider files', () => {
it('together hold the same ping count as the canonical truth', () => {
const rawTotal = northwind.length + haulix.length + tracpoint.length
expect(rawTotal).toBe(truth.locations.length)
})
})
46 changes: 46 additions & 0 deletions src/core/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Canonical FleetBridge data model. Every provider adapter normalizes raw
// telematics into these shapes, and the rest of the app reads only these.

export interface Provider {
id: string
name: string
}

export interface Vehicle {
id: string
label: string
providerId: string
vin: string
}

export interface Location {
vehicleId: string
/** ISO 8601 timestamp, e.g. "2026-06-15T13:00:10.000Z". */
timestamp: string
lat: number
lng: number
speedKmh: number
headingDeg: number
}
Comment on lines +16 to +24

export type SafetyEventType =
| 'harsh_brake'
| 'harsh_accel'
| 'speeding'
| 'idling'

export interface SafetyEvent {
id: string
vehicleId: string
type: SafetyEventType
/** ISO 8601 timestamp aligned to the Location it was derived from. */
timestamp: string
lat: number
lng: number
}

export const NORTHWIND: Provider = { id: 'northwind', name: 'Northwind' }
export const HAULIX: Provider = { id: 'haulix', name: 'Haulix' }
export const TRACPOINT: Provider = { id: 'tracpoint', name: 'TracPoint' }

export const PROVIDERS: Provider[] = [NORTHWIND, HAULIX, TRACPOINT]
Comment on lines +42 to +46
33 changes: 33 additions & 0 deletions src/core/rng.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Deterministic pseudo-random number generator. A fixed seed makes every
// data generation run produce byte-identical output, so the committed
// fixtures stay reproducible.

export type Rng = () => number

/** mulberry32: tiny, fast, good-enough PRNG returning a float in [0, 1). */
export function mulberry32(seed: number): Rng {
let a = seed >>> 0
return function () {
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}

/** Fixed seed shared by the generator so output never drifts. */
export const DEFAULT_SEED = 0x46_42_72_67 // "FBrg"

export function createRng(seed: number = DEFAULT_SEED): Rng {
return mulberry32(seed)
}

/** Float in [min, max). */
export function randRange(rng: Rng, min: number, max: number): number {
return min + (max - min) * rng()
}

/** Integer in [min, max] inclusive (rng() is in [0, 1), so max is reachable). */
export function randInt(rng: Rng, min: number, max: number): number {
return Math.floor(min + (max - min + 1) * rng())
}
Empty file removed src/data/raw/.gitkeep
Empty file.
Loading