-
Notifications
You must be signed in to change notification settings - Fork 77
fix: smooth blind spot monitor #685
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8f5b397
2734d93
ad9f666
aad9047
a3ffdce
1cd5481
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import type { Session, Telemetry } from '@irdashies/types'; | ||
| import recordedSession from '../../../test-data/1747384033336/session.json'; | ||
| import recordedOverlapFrame from '../../../test-data/1747384033336/telemetry.json'; | ||
| import recordedClearFrame from '../../../test-data/1770713920383/telemetry.json'; | ||
| import { BlindSpotProcessor } from './BlindSpotProcessor'; | ||
|
|
||
| const frame = (carLeftRight: number, positions: number[], isOnTrack = true) => | ||
| ({ | ||
| CarLeftRight: { value: [carLeftRight] }, | ||
| CarIdxLapDistPct: { value: positions }, | ||
| IsOnTrack: { value: [isOnTrack] }, | ||
| }) as unknown as Telemetry; | ||
|
|
||
| describe('BlindSpotProcessor', () => { | ||
| it('processes recorded telemetry through the complete lifecycle', () => { | ||
| const processor = new BlindSpotProcessor(); | ||
| processor.init(recordedSession as unknown as Session); | ||
| processor.onFrame(recordedOverlapFrame as unknown as Telemetry); | ||
|
|
||
| expect(processor.snapshot()).toMatchObject({ | ||
| carLeftRight: 2, | ||
| carIdxLapDistPct: recordedOverlapFrame.CarIdxLapDistPct.value, | ||
| isOnTrack: true, | ||
| version: 1, | ||
| }); | ||
|
|
||
| processor.onFrame(recordedClearFrame as unknown as Telemetry); | ||
|
|
||
| expect(processor.snapshot()).toMatchObject({ | ||
| carLeftRight: 1, | ||
| carIdxLapDistPct: [], | ||
| isOnTrack: true, | ||
| version: 2, | ||
| }); | ||
| }); | ||
|
|
||
| it('publishes overlap state and full-precision positions together', () => { | ||
| const processor = new BlindSpotProcessor(); | ||
| processor.onFrame(frame(2, [0.123456, 0.123789])); | ||
|
|
||
| expect(processor.snapshot()).toMatchObject({ | ||
| carLeftRight: 2, | ||
| carIdxLapDistPct: [0.123456, 0.123789], | ||
| isOnTrack: true, | ||
| version: 1, | ||
| }); | ||
| }); | ||
|
|
||
| it('does not copy or publish moving positions while there is no overlap', () => { | ||
| const processor = new BlindSpotProcessor(); | ||
| processor.onFrame(frame(1, [0.1, 0.2])); | ||
| const idleVersion = processor.snapshot().version; | ||
|
|
||
| processor.onFrame(frame(1, [0.11, 0.21])); | ||
|
|
||
| expect(processor.snapshot()).toMatchObject({ | ||
| carLeftRight: 1, | ||
| carIdxLapDistPct: [], | ||
| isOnTrack: true, | ||
| version: idleVersion, | ||
| }); | ||
| }); | ||
|
|
||
| it('stops publishing positions when an overlap clears', () => { | ||
| const processor = new BlindSpotProcessor(); | ||
| processor.onFrame(frame(2, [0.5, 0.5005])); | ||
| processor.onFrame(frame(1, [0.51, 0.52])); | ||
|
|
||
| expect(processor.snapshot()).toMatchObject({ | ||
| carLeftRight: 1, | ||
| carIdxLapDistPct: [], | ||
| isOnTrack: true, | ||
| version: 2, | ||
| }); | ||
| }); | ||
|
|
||
| it('clears safety state at lifecycle boundaries', () => { | ||
| const processor = new BlindSpotProcessor(); | ||
| processor.onFrame(frame(3, [0.5, 0.6])); | ||
| processor.onLifecycle({ type: 'disconnect' }); | ||
|
|
||
| expect(processor.snapshot()).toMatchObject({ | ||
| carLeftRight: 0, | ||
| carIdxLapDistPct: [], | ||
| isOnTrack: false, | ||
| version: 2, | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import type { | ||
| BlindSpotSnapshot, | ||
| Session, | ||
| SessionLifecycleEvent, | ||
| Telemetry, | ||
| } from '@irdashies/types'; | ||
| import { CarLeftRight } from '@irdashies/types'; | ||
| import type { TelemetryProcessor } from './TelemetryProcessor'; | ||
|
|
||
| const scalar = (frame: Telemetry, key: string): unknown => | ||
| (frame as unknown as Record<string, { value?: unknown[] } | undefined>)[key] | ||
| ?.value?.[0]; | ||
|
|
||
| const copyPositions = (target: number[], frame: Telemetry): boolean => { | ||
| const source = frame.CarIdxLapDistPct?.value ?? []; | ||
| let changed = target.length !== source.length; | ||
| target.length = source.length; | ||
| for (let index = 0; index < source.length; index += 1) { | ||
| if (target[index] !== source[index]) changed = true; | ||
| target[index] = source[index]; | ||
| } | ||
| return changed; | ||
| }; | ||
|
|
||
| export class BlindSpotProcessor implements TelemetryProcessor<BlindSpotSnapshot> { | ||
| readonly channel = 'blind-spot.snapshot'; | ||
| readonly tickRateHz = 25; | ||
|
|
||
| private readonly latest: BlindSpotSnapshot = { | ||
| carLeftRight: 0, | ||
| carIdxLapDistPct: [], | ||
| isOnTrack: false, | ||
| version: 0, | ||
| }; | ||
|
|
||
| init(session: Session): void { | ||
| void session; | ||
| } | ||
|
|
||
| onFrame(frame: Telemetry): void { | ||
| const rawCarLeftRight = scalar(frame, 'CarLeftRight'); | ||
| const carLeftRight = | ||
| typeof rawCarLeftRight === 'number' ? rawCarLeftRight : CarLeftRight.Off; | ||
| const isOnTrack = scalar(frame, 'IsOnTrack'); | ||
| let changed = this.set('carLeftRight', carLeftRight); | ||
| changed = | ||
| this.set('isOnTrack', isOnTrack === true || isOnTrack === 1) || changed; | ||
|
|
||
| const positions = this.latest.carIdxLapDistPct as number[]; | ||
| if (carLeftRight > CarLeftRight.Clear) { | ||
| changed = copyPositions(positions, frame) || changed; | ||
| } else if (positions.length > 0) { | ||
| positions.length = 0; | ||
| changed = true; | ||
| } | ||
|
|
||
| if (changed) this.latest.version += 1; | ||
| } | ||
|
|
||
| onLifecycle(event: SessionLifecycleEvent): void { | ||
| if (event.type === 'enter') return; | ||
| (this.latest.carIdxLapDistPct as number[]).length = 0; | ||
| this.latest.carLeftRight = 0; | ||
| this.latest.isOnTrack = false; | ||
| this.latest.version += 1; | ||
| } | ||
|
|
||
| snapshot(): BlindSpotSnapshot { | ||
| return this.latest; | ||
| } | ||
|
|
||
| private set<K extends 'carLeftRight' | 'isOnTrack'>( | ||
| key: K, | ||
| value: BlindSpotSnapshot[K] | ||
| ): boolean { | ||
| if (this.latest[key] === value) return false; | ||
| this.latest[key] = value; | ||
| return true; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,7 @@ | ||
| import type { | ||
| LapTimesSnapshot, | ||
| ReferenceLapsSnapshot, | ||
| } from '@irdashies/types'; | ||
| import type { LapTimesSnapshot, ReferenceLapsSnapshot } from '@irdashies/types'; | ||
| import type { ReferenceLapPersistence } from './ReferenceLapProcessor'; | ||
| import { CarSpeedsProcessor } from './CarSpeedsProcessor'; | ||
| import { BlindSpotProcessor } from './BlindSpotProcessor'; | ||
| import { DriverControlsProcessor } from './DriverControlsProcessor'; | ||
| import { FuelProjectionProcessor } from './FuelProjectionProcessor'; | ||
| import { LapLogProcessor } from './LapLogProcessor'; | ||
|
|
@@ -57,11 +55,15 @@ const defineProcessor = <K extends AnyProcessorDefinition['channel']>( | |
| export const createProcessorDefinitions = ({ | ||
| referenceLapPersistence, | ||
| }: ProcessorRegistryOptions): readonly AnyProcessorDefinition[] => [ | ||
| defineProcessor({ | ||
| channel: 'blind-spot.snapshot', | ||
| metricsPrefix: 'blindSpot', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When performance metrics are enabled for this 60 Hz processor, AGENTS.md reference: AGENTS.md:L12-L18 Useful? React with 👍 / 👎. |
||
| create: () => new BlindSpotProcessor(), | ||
| }), | ||
| defineProcessor({ | ||
| channel: 'fuel.projection', | ||
| metricsPrefix: 'fuelProjection', | ||
| create: ({ sourceReplay }) => | ||
| new FuelProjectionProcessor({ sourceReplay }), | ||
| create: ({ sourceReplay }) => new FuelProjectionProcessor({ sourceReplay }), | ||
| }), | ||
| defineProcessor({ | ||
| channel: 'lap-times.snapshot', | ||
|
|
@@ -90,8 +92,7 @@ export const createProcessorDefinitions = ({ | |
| metricsPrefix: 'relativeGap', | ||
| create: ({ snapshot }) => | ||
| new RelativeGapProcessor({ | ||
| snapshot: () => | ||
| snapshot('reference-laps.snapshot') ?? noReferenceLaps, | ||
| snapshot: () => snapshot('reference-laps.snapshot') ?? noReferenceLaps, | ||
| }), | ||
| }), | ||
| defineProcessor({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { renderHook } from '@testing-library/react'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { CarLeftRight, type BlindSpotSnapshot } from '@irdashies/types'; | ||
| import { useBlindSpotMonitor } from './useBlindSpotMonitor'; | ||
|
|
||
| let blindSpotSnapshot: BlindSpotSnapshot; | ||
|
|
||
| vi.mock('@irdashies/context', () => ({ | ||
| useDriverCarIdx: () => 0, | ||
| useTrackLength: () => 5000, | ||
| useBlindSpotSelector: (selector: (snapshot: BlindSpotSnapshot) => unknown) => | ||
| selector(blindSpotSnapshot), | ||
| })); | ||
|
|
||
| vi.mock('./useBlindSpotMonitorSettings', () => ({ | ||
| useBlindSpotMonitorSettings: () => ({ distAhead: 4, distBehind: 4 }), | ||
| })); | ||
|
|
||
| const snapshot = (rivalProgress: number): BlindSpotSnapshot => ({ | ||
| carLeftRight: CarLeftRight.CarLeft, | ||
| carIdxLapDistPct: [0.5, rivalProgress], | ||
| isOnTrack: true, | ||
| version: 1, | ||
| }); | ||
|
|
||
| describe('useBlindSpotMonitor', () => { | ||
| beforeEach(() => { | ||
| blindSpotSnapshot = snapshot(0.5004); | ||
| }); | ||
|
|
||
| it('does not enter a render loop while tracking an adjacent car', () => { | ||
| let renderCount = 0; | ||
| const { result, rerender } = renderHook(() => { | ||
| renderCount += 1; | ||
| return useBlindSpotMonitor(); | ||
| }); | ||
|
|
||
| expect(result.current.show).toBe(true); | ||
| expect(result.current.isOnTrack).toBe(true); | ||
| expect(result.current.leftState).toBe(CarLeftRight.CarLeft); | ||
|
|
||
| blindSpotSnapshot = snapshot(0.5005); | ||
| rerender(); | ||
|
|
||
| expect(result.current.leftPercent).toBeGreaterThan(0); | ||
| expect(renderCount).toBeLessThan(10); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new processor coverage constructs
BlindSpotProcessorand callsonFramedirectly with a hand-built object; none of these tests callsinit(session)or drives a recorded frame sequence. This leaves activation/session initialization and compatibility with recorded telemetry shapes untested, contrary to the mandatory fixture-backedinit → onFrame* → snapshotsequence indocs/ARCHITECTURE_RULES.mdR14.1; add coverage that exercises the complete lifecycle.AGENTS.md reference: AGENTS.md:L12-L18
Useful? React with 👍 / 👎.