From 9db844aeec622e770874fa43d972c8081a503de5 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Thu, 17 Sep 2026 19:52:51 -0700 Subject: [PATCH] fix(web): tolerate null drive-ledger dynamics on drive detail JSON null / omitted nested ledgers made DriveLedgerCompactPanel read regen_wh on undefined and trip the energy-ledger error boundary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b104204-65b7-4d04-90a4-e2d5897b8e2e --- web/src/api/types.ts | 22 ++-- .../DriveLedgerCompactPanel.test.tsx | 121 ++++++++++++++++++ .../drive-detail/DriveLedgerCompactPanel.tsx | 8 +- 3 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 web/src/features/driving/components/drive-detail/DriveLedgerCompactPanel.test.tsx diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c10389e82..c66bff34f 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -3793,17 +3793,19 @@ export interface PhysicsLedger { kind: string start: string end: string - dynamics: PhysicsLongitudinalDynamics - drive: PhysicsDriveLedger - charge: PhysicsChargeLedger - park: PhysicsParkLedger - thermal: PhysicsThermalLedger - range: PhysicsRangeLedger - tires: PhysicsTireLedger - epochs: PhysicsEpochResidual[] - unknown_intervals: PhysicsUnknownInterval[] + // Nested ledgers are Go pointers; encoding/json emits null (or omits + // after camelCase transforms) when that domain has no samples. + dynamics: PhysicsLongitudinalDynamics | null + drive: PhysicsDriveLedger | null + charge: PhysicsChargeLedger | null + park: PhysicsParkLedger | null + thermal: PhysicsThermalLedger | null + range: PhysicsRangeLedger | null + tires: PhysicsTireLedger | null + epochs: PhysicsEpochResidual[] | null + unknown_intervals: PhysicsUnknownInterval[] | null unknown_hours: number - black_box: PhysicsBlackBoxPoint[] + black_box: PhysicsBlackBoxPoint[] | null contradictions?: string[] markers?: PhysicsMarker[] truncated: boolean diff --git a/web/src/features/driving/components/drive-detail/DriveLedgerCompactPanel.test.tsx b/web/src/features/driving/components/drive-detail/DriveLedgerCompactPanel.test.tsx new file mode 100644 index 000000000..ddc98666b --- /dev/null +++ b/web/src/features/driving/components/drive-detail/DriveLedgerCompactPanel.test.tsx @@ -0,0 +1,121 @@ +/** + * DriveLedgerCompactPanel — Go nil nested ledgers must not crash. + * + * encoding/json marshals a nil *LongitudinalDynamics as JSON null. After + * camelCaseKeys the field can also be missing (undefined). Accessing + * `.regen_wh` on that value used to throw and trip the drive-detail + * energy-ledger error boundary. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { PhysicsLedger } from '@/api/types'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})); + +const { useDriveLedgerMock } = vi.hoisted(() => ({ + useDriveLedgerMock: vi.fn(), +})); + +vi.mock('@/api/hooks/usePhysicsLedger', () => ({ + useDriveLedger: useDriveLedgerMock, +})); + +import { DriveLedgerCompactPanel } from './DriveLedgerCompactPanel'; + +function queryState(over: Record = {}) { + return { + data: undefined, + dataUpdatedAt: Date.now(), + error: null, + isError: false, + isPending: false, + isLoading: false, + isFetching: false, + isSuccess: true, + status: 'success', + fetchStatus: 'idle', + refetch: vi.fn(), + ...over, + }; +} + +function ledgerStub(over: Partial = {}): PhysicsLedger { + return { + vehicle_id: 1, + kind: 'drive', + start: '2026-09-17T12:00:00Z', + end: '2026-09-17T13:00:00Z', + dynamics: null, + drive: null, + charge: null, + park: null, + thermal: null, + range: null, + tires: null, + epochs: null, + unknown_intervals: null, + unknown_hours: 0, + black_box: null, + truncated: false, + honesty: 'Predicted vs measured.', + ...over, + }; +} + +function renderPanel() { + return render( + + + , + ); +} + +describe('DriveLedgerCompactPanel', () => { + beforeEach(() => { + useDriveLedgerMock.mockReset(); + }); + + it('renders regen/friction as Unknown when dynamics is JSON null', () => { + useDriveLedgerMock.mockReturnValue(queryState({ data: ledgerStub({ dynamics: null }) })); + expect(() => renderPanel()).not.toThrow(); + expect(screen.getByTestId('drive-ledger-compact')).toBeInTheDocument(); + expect(screen.getByText(/Regen/)).toHaveTextContent(/Unknown/); + expect(screen.getByText(/Friction brake/)).toHaveTextContent(/Unknown/); + }); + + it('does not crash when dynamics is omitted (undefined)', () => { + const data = ledgerStub(); + delete (data as { dynamics?: PhysicsLedger['dynamics'] }).dynamics; + useDriveLedgerMock.mockReturnValue(queryState({ data })); + expect(() => renderPanel()).not.toThrow(); + expect(screen.getByTestId('drive-ledger-compact')).toBeInTheDocument(); + expect(screen.getByText(/Regen/)).toHaveTextContent(/Unknown/); + }); + + it('shows formatted regen when dynamics is present', () => { + useDriveLedgerMock.mockReturnValue( + queryState({ + data: ledgerStub({ + dynamics: { + points: [], + mass_kg: null, + mass_source: 'unknown', + regen_wh: 4880, + friction_brake_wh: 0, + unknown: false, + honesty: 'Regen is pack charge current while moving.', + }, + }), + }), + ); + renderPanel(); + expect(screen.getByText(/Regen/)).not.toHaveTextContent(/Unknown/); + }); +}); diff --git a/web/src/features/driving/components/drive-detail/DriveLedgerCompactPanel.tsx b/web/src/features/driving/components/drive-detail/DriveLedgerCompactPanel.tsx index 5f6d97b8a..1bbe0a37c 100644 --- a/web/src/features/driving/components/drive-detail/DriveLedgerCompactPanel.tsx +++ b/web/src/features/driving/components/drive-detail/DriveLedgerCompactPanel.tsx @@ -32,11 +32,15 @@ export function DriveLedgerCompactPanel({ driveId }: { driveId: string | undefin
{t('driveDetail.ledger.regen', 'Regen')}:{' '} - {ledger.dynamics.regen_wh != null ? formatEnergy(ledger.dynamics.regen_wh) : t('common.unknown', 'Unknown')} + {ledger.dynamics?.regen_wh != null + ? formatEnergy(ledger.dynamics.regen_wh) + : t('common.unknown', 'Unknown')} {t('driveDetail.ledger.friction', 'Friction brake')}:{' '} - {ledger.dynamics.friction_brake_wh != null ? formatEnergy(ledger.dynamics.friction_brake_wh) : t('common.unknown', 'Unknown')} + {ledger.dynamics?.friction_brake_wh != null + ? formatEnergy(ledger.dynamics.friction_brake_wh) + : t('common.unknown', 'Unknown')} {ledger.truncated ? (