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
1 change: 1 addition & 0 deletions src/app/perfMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ interface ChannelMetricsSource {
}

const PROCESSOR_CHANNELS: Readonly<Record<string, string>> = {
blindSpotProcessing: 'blind-spot.snapshot',
carSpeedsProcessing: 'car-speeds.snapshot',
driverControlsProcessing: 'driver-controls.snapshot',
fuelProjectionProcessing: 'fuel.projection',
Expand Down
90 changes: 90 additions & 0 deletions src/app/processors/BlindSpotProcessor.spec.ts
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]));
Comment on lines +39 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Drive the processor test through the required lifecycle

The new processor coverage constructs BlindSpotProcessor and calls onFrame directly with a hand-built object; none of these tests calls init(session) or drives a recorded frame sequence. This leaves activation/session initialization and compatibility with recorded telemetry shapes untested, contrary to the mandatory fixture-backed init → onFrame* → snapshot sequence in docs/ARCHITECTURE_RULES.md R14.1; add coverage that exercises the complete lifecycle.

AGENTS.md reference: AGENTS.md:L12-L18

Useful? React with 👍 / 👎.


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,
});
});
});
80 changes: 80 additions & 0 deletions src/app/processors/BlindSpotProcessor.ts
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;
}
}
17 changes: 9 additions & 8 deletions src/app/processors/processorRegistry.ts
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';
Expand Down Expand Up @@ -57,11 +55,15 @@ const defineProcessor = <K extends AnyProcessorDefinition['channel']>(
export const createProcessorDefinitions = ({
referenceLapPersistence,
}: ProcessorRegistryOptions): readonly AnyProcessorDefinition[] => [
defineProcessor({
channel: 'blind-spot.snapshot',
metricsPrefix: 'blindSpot',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add blind-spot to processor execution metrics

When performance metrics are enabled for this 60 Hz processor, ProcessorHost records the blindSpotProcessing section, but perfMetrics.ts does not map that label in PROCESSOR_CHANNELS. As a result, generated channelMetrics.processorExecutions reports no executions for blind-spot.snapshot, so the performance analyzer shows a zero processor rate and cannot accurately evaluate this new hot path; add the corresponding section-to-channel mapping.

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',
Expand Down Expand Up @@ -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({
Expand Down
12 changes: 3 additions & 9 deletions src/frontend/components/BlindSpotMonitor/BlindSpotMonitor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ import { useBlindSpotMonitor } from './hooks/useBlindSpotMonitor';
import { useBlindSpotMonitorSettings } from './hooks/useBlindSpotMonitorSettings';
import { BlindSpotMonitorIndicator } from './components/BlindSpotMonitorIndicator';
import { BlindSpotMonitorSimpleIndicator } from './components/BlindSpotMonitorSimpleIndicator';
import {
useDashboard,
useSessionVisibility,
trackStateSelectors,
useTrackStateSelector,
} from '@irdashies/context';
import { useDashboard, useSessionVisibility } from '@irdashies/context';
import { CarLeftRight } from '@irdashies/types';

export interface BlindSpotMonitorDisplayProps {
Expand Down Expand Up @@ -137,14 +132,13 @@ export const BlindSpotMonitor = () => {
const state = useBlindSpotMonitor();
const settings = useBlindSpotMonitorSettings();
const { isDemoMode } = useDashboard();
const isOnTrack =
useTrackStateSelector(trackStateSelectors.isOnTrack) ?? false;

const sessionVisible = useSessionVisibility(settings?.sessionVisibility);
const activeState = isDemoMode ? DEMO_STATE_SIMPLE : state;

if (!isDemoMode && !sessionVisible) return <></>;
if (!isDemoMode && settings?.showOnlyWhenOnTrack && !isOnTrack) return <></>;
if (!isDemoMode && settings?.showOnlyWhenOnTrack && !state.isOnTrack)
return <></>;

return (
<BlindSpotMonitorDisplay
Expand Down
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);
});
});
Loading