32d93bba2a
- self-contained RecordsMap (only module importing leaflet/react-leaflet/ leaflet.heat/leaflet.markercluster); OSM tiles, swappable behind clean props - heatmap layers for location + poo (primary); time-range selector fetches only the window (locations server-filtered; poo client-filtered) - toggleable scatter layer with marker clustering; point-select reuses T10's edit/delete modals + hooks; query-key prefixes refresh map on mutation - pure map logic isolated + unit-tested; leaflet mocked in component tests - responsive layout; typed client only
197 lines
6.0 KiB
TypeScript
197 lines
6.0 KiB
TypeScript
/**
|
|
* Unit tests for mapUtils.ts — pure logic, no leaflet, runs in jsdom.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
locationsToHeatPoints,
|
|
pooToHeatPoints,
|
|
locationsToMapPoints,
|
|
pooToMapPoints,
|
|
filterPooByTimeWindow,
|
|
computeCenter,
|
|
daysAgoISO,
|
|
} from './mapUtils'
|
|
import type { LocationRecord, PooRecord } from '../records'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const loc1: LocationRecord = {
|
|
person: 'alice',
|
|
datetime: '2026-01-15T10:00:00Z',
|
|
latitude: 39.9,
|
|
longitude: 116.4,
|
|
altitude: 50,
|
|
}
|
|
const loc2: LocationRecord = {
|
|
person: 'alice',
|
|
datetime: '2026-01-20T12:00:00Z',
|
|
latitude: 39.95,
|
|
longitude: 116.45,
|
|
altitude: null,
|
|
}
|
|
|
|
const poo1: PooRecord = {
|
|
timestamp: '2026-01-10T08:00:00Z',
|
|
status: 'done',
|
|
latitude: 39.91,
|
|
longitude: 116.41,
|
|
}
|
|
const poo2: PooRecord = {
|
|
timestamp: '2026-01-20T09:00:00Z',
|
|
status: 'done',
|
|
latitude: 39.92,
|
|
longitude: 116.42,
|
|
}
|
|
const poo3: PooRecord = {
|
|
timestamp: '2026-02-01T09:00:00Z',
|
|
status: 'done',
|
|
latitude: 39.93,
|
|
longitude: 116.43,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// locationsToHeatPoints
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('locationsToHeatPoints', () => {
|
|
it('converts records to [lat, lng, 1] tuples', () => {
|
|
const pts = locationsToHeatPoints([loc1, loc2])
|
|
expect(pts).toHaveLength(2)
|
|
expect(pts[0]).toEqual([39.9, 116.4, 1])
|
|
expect(pts[1]).toEqual([39.95, 116.45, 1])
|
|
})
|
|
|
|
it('returns empty array for empty input', () => {
|
|
expect(locationsToHeatPoints([])).toEqual([])
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// pooToHeatPoints
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('pooToHeatPoints', () => {
|
|
it('converts poo records to heat points', () => {
|
|
const pts = pooToHeatPoints([poo1])
|
|
expect(pts).toHaveLength(1)
|
|
expect(pts[0]).toEqual([39.91, 116.41, 1])
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// locationsToMapPoints
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('locationsToMapPoints', () => {
|
|
it('attaches original record to each point', () => {
|
|
const pts = locationsToMapPoints([loc1])
|
|
expect(pts).toHaveLength(1)
|
|
expect(pts[0].lat).toBe(39.9)
|
|
expect(pts[0].lng).toBe(116.4)
|
|
expect(pts[0].record).toBe(loc1)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// pooToMapPoints
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('pooToMapPoints', () => {
|
|
it('attaches original poo record to each point', () => {
|
|
const pts = pooToMapPoints([poo1])
|
|
expect(pts[0].record).toBe(poo1)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// filterPooByTimeWindow — client-side time filter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('filterPooByTimeWindow', () => {
|
|
const records = [poo1, poo2, poo3]
|
|
// timestamps: 2026-01-10, 2026-01-20, 2026-02-01
|
|
|
|
it('returns all records when start and end are both null', () => {
|
|
expect(filterPooByTimeWindow(records, null, null)).toHaveLength(3)
|
|
})
|
|
|
|
it('filters by start (inclusive)', () => {
|
|
const result = filterPooByTimeWindow(records, '2026-01-15T00:00:00Z', null)
|
|
expect(result).toHaveLength(2)
|
|
expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z')
|
|
expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z')
|
|
})
|
|
|
|
it('filters by end (inclusive)', () => {
|
|
const result = filterPooByTimeWindow(records, null, '2026-01-20T09:00:00Z')
|
|
expect(result).toHaveLength(2)
|
|
expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z')
|
|
expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z')
|
|
})
|
|
|
|
it('filters by both start and end', () => {
|
|
const result = filterPooByTimeWindow(
|
|
records,
|
|
'2026-01-15T00:00:00Z',
|
|
'2026-01-25T00:00:00Z',
|
|
)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].timestamp).toBe('2026-01-20T09:00:00Z')
|
|
})
|
|
|
|
it('returns empty when no records match', () => {
|
|
const result = filterPooByTimeWindow(records, '2027-01-01T00:00:00Z', null)
|
|
expect(result).toHaveLength(0)
|
|
})
|
|
|
|
it('includes records exactly at start boundary', () => {
|
|
const result = filterPooByTimeWindow(records, '2026-01-10T08:00:00Z', null)
|
|
expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z')
|
|
})
|
|
|
|
it('includes records exactly at end boundary', () => {
|
|
const result = filterPooByTimeWindow(records, null, '2026-02-01T09:00:00Z')
|
|
expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// computeCenter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('computeCenter', () => {
|
|
it('returns null for empty array', () => {
|
|
expect(computeCenter([])).toBeNull()
|
|
})
|
|
|
|
it('returns the point for a single-element array', () => {
|
|
const result = computeCenter([{ lat: 10, lng: 20 }])
|
|
expect(result).toEqual([10, 20])
|
|
})
|
|
|
|
it('returns the average of multiple points', () => {
|
|
const result = computeCenter([
|
|
{ lat: 0, lng: 0 },
|
|
{ lat: 4, lng: 6 },
|
|
])
|
|
expect(result).toEqual([2, 3])
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// daysAgoISO
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('daysAgoISO', () => {
|
|
it('returns a valid ISO string in the past', () => {
|
|
const result = daysAgoISO(7)
|
|
expect(typeof result).toBe('string')
|
|
const d = new Date(result)
|
|
expect(isNaN(d.getTime())).toBe(false)
|
|
expect(d.getTime()).toBeLessThan(Date.now())
|
|
})
|
|
})
|