Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
65 changes: 65 additions & 0 deletions src/app/processors/BlindSpotProcessor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import type { Telemetry } from '@irdashies/types';
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('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 = 60;

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 Keep the positional blind-spot processor at 25 Hz

When the blind-spot widget is enabled, every full-precision CarIdxLapDistPct change increments the snapshot version, so this setting serializes and delivers the complete position array at 60 Hz. This is a positional channel rather than an input-style channel, and the repository's hard architecture rules cap positional processors at 25 Hz; use the existing 40 ms indicator transition to smooth those samples instead of doubling the processor and IPC workload.

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

Useful? React with 👍 / 👎.


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
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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.leftState).toBe(CarLeftRight.CarLeft);

blindSpotSnapshot = snapshot(0.5005);
rerender();

expect(result.current.leftPercent).toBeGreaterThan(0);
expect(renderCount).toBeLessThan(10);
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useMemo, useState, useEffect } from 'react';
import type { TrackStateSnapshot } from '@irdashies/types';
import { useMemo, useState, useEffect, useRef } from 'react';
import type { BlindSpotSnapshot } from '@irdashies/types';
import { shallow } from 'zustand/shallow';
import {
useTrackStateSelector,
useBlindSpotSelector,
useDriverCarIdx,
useTrackLength,
} from '@irdashies/context';
Expand All @@ -19,13 +19,8 @@ interface BlindSpotMonitorState {
}

const EMPTY_POSITIONS: readonly number[] = [];
const EMPTY_BLIND_SPOT_TELEMETRY: readonly [
CarLeftRight,
readonly number[],
boolean,
] = [CarLeftRight.Off, EMPTY_POSITIONS, false];

const selectBlindSpotTelemetry = (snapshot: TrackStateSnapshot) =>
const TELEPORT_THRESHOLD = 0.5;
const selectBlindSpotTelemetry = (snapshot: BlindSpotSnapshot) =>
[
snapshot.carLeftRight as CarLeftRight,
snapshot.carIdxLapDistPct,
Expand All @@ -41,23 +36,22 @@ const blindSpotTelemetryEqual = (
previous[2] === next[2];

export const useBlindSpotMonitor = (): BlindSpotMonitorState => {
const [carLeftRight, lapDistPcts, isOnTrack] =
useTrackStateSelector(selectBlindSpotTelemetry, {
const [carLeftRight, lapDistPcts, isOnTrack] = useBlindSpotSelector(

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 Stop retaining the track-state subscription

When Blind Spot Monitor is the only positional widget, adding this selector activates the new blind-spot processor while BlindSpotMonitor.tsx still calls useTrackStateSelector(trackStateSelectors.isOnTrack) for its visibility check. Runtime channel metadata does not suppress hook subscriptions, so both the 60 Hz blind-spot processor and the 25 Hz track-state processor remain active and isOnTrack is delivered twice, negating the intended replacement of the general positional channel; use the dedicated snapshot's on-track value for that visibility decision as well.

Useful? React with 👍 / 👎.

selectBlindSpotTelemetry,
{
equality: blindSpotTelemetryEqual,
}) ?? EMPTY_BLIND_SPOT_TELEMETRY;
}
) ?? [CarLeftRight.Off, EMPTY_POSITIONS, false];
const driverCarIdx = useDriverCarIdx() ?? 0;
const trackLength = useTrackLength();
const settings = useBlindSpotMonitorSettings();

const [leftCarIdx, setLeftCarIdx] = useState<number | null>(null);
const [rightCarIdx, setRightCarIdx] = useState<number | null>(null);
const [prevPercents, setPrevPercents] = useState<{
const prevPercentsRef = useRef<{
left: number | null;
right: number | null;
}>({
left: null,
right: null,
});
}>({ left: null, right: null });

const result = useMemo(() => {
const defaultState = {
Expand Down Expand Up @@ -127,9 +121,10 @@ export const useBlindSpotMonitor = (): BlindSpotMonitorState => {
leftPercent =
is3Wide && leftCarIdx === null ? 0 : calculatePercent(leftCarIdx);

const previousLeft = prevPercentsRef.current.left;
if (
prevPercents.left !== null &&
Math.abs(prevPercents.left - leftPercent) > 0.5
previousLeft !== null &&
Math.abs(previousLeft - leftPercent) > TELEPORT_THRESHOLD
) {
disableTransition = true;
}
Expand All @@ -145,9 +140,10 @@ export const useBlindSpotMonitor = (): BlindSpotMonitorState => {
rightPercent =
is3Wide && rightCarIdx === null ? 0 : calculatePercent(rightCarIdx);

const previousRight = prevPercentsRef.current.right;
if (
prevPercents.right !== null &&
Math.abs(prevPercents.right - rightPercent) > 0.5
previousRight !== null &&
Math.abs(previousRight - rightPercent) > TELEPORT_THRESHOLD
) {
disableTransition = true;
}
Expand All @@ -170,14 +166,13 @@ export const useBlindSpotMonitor = (): BlindSpotMonitorState => {
isOnTrack,
leftCarIdx,
rightCarIdx,
prevPercents,
]);

useEffect(() => {
if (carLeftRight <= CarLeftRight.Clear) {
setLeftCarIdx(null);
setRightCarIdx(null);
setPrevPercents({ left: null, right: null });
prevPercentsRef.current = { left: null, right: null };
return;
}

Expand Down Expand Up @@ -225,10 +220,10 @@ export const useBlindSpotMonitor = (): BlindSpotMonitorState => {
// If BOTH are null (fresh 3-wide), we stay at 0%
}

setPrevPercents({
prevPercentsRef.current = {
left: result.leftPercent !== 0 ? result.leftPercent : null,
right: result.rightPercent !== 0 ? result.rightPercent : null,
});
};
}, [
result.show,
carLeftRight,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { WidgetRuntimeDefinition } from '../../widgetRuntime';
export default {
id: 'blindspotmonitor',
sessionData: true,
channels: ['track-state.snapshot'],
channels: ['blind-spot.snapshot'],
ratePreset: 'driverFocused',
channelRates: { 'blind-spot.snapshot': 60 },

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 Align the requested rate with the 25 Hz source

In the live iRacing path, publishIRacingSDKEvents sleeps for 1000 / 25 after every telemetry frame (src/app/bridge/iracingSdk/iracingSdkBridge.ts:295-297), so BlindSpotProcessor.onFrame cannot receive 60 frames per second and this request cannot provide the claimed 60 Hz overlap or movement updates. Fresh evidence beyond the earlier processor-rate comment is this fixed upstream 40 ms throttle: advertising 60 Hz here creates a false runtime/performance contract without increasing data cadence; keep the positional channel at 25 Hz and smooth those samples in presentation instead.

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

Useful? React with 👍 / 👎.

} satisfies WidgetRuntimeDefinition;
1 change: 1 addition & 0 deletions src/frontend/context/ChannelStore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export * from './useSessionBarSnapshot';
export * from './useSectorTimingSnapshot';
export * from './useStandingsSnapshot';
export * from './useCarSpeedsSnapshot';
export * from './useBlindSpotSnapshot';
export * from './useDriverControlsSnapshot';
export * from './useTrackStateSnapshot';
10 changes: 10 additions & 0 deletions src/frontend/context/ChannelStore/useBlindSpotSnapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { BlindSpotSnapshot } from '@irdashies/types';
import {
useChannelSelector,
type ChannelSelectorOptions,
} from './useChannelSnapshot';

export const useBlindSpotSelector = <Selected>(
selector: (snapshot: BlindSpotSnapshot) => Selected,
options: ChannelSelectorOptions<Selected> = {}
) => useChannelSelector('blind-spot.snapshot', selector, options);
7 changes: 7 additions & 0 deletions src/frontend/widgetRuntime.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ describe('widget runtime metadata', () => {
).toBe(false);
});

it('requests a dedicated 60 Hz blind-spot snapshot', () => {
expect(getWidgetRuntimeDefinition('blindspotmonitor')).toMatchObject({
channels: ['blind-spot.snapshot'],
channelRates: { 'blind-spot.snapshot': 60 },
});
});

it('declares standings and relative as channel-only consumers', () => {
expect(getWidgetRuntimeDefinition('standings')).toMatchObject({
channels: [
Expand Down
Loading