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
22 changes: 12 additions & 10 deletions web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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> = {}): 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(
<MemoryRouter>
<DriveLedgerCompactPanel driveId="393" />
</MemoryRouter>,
);
}

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/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,15 @@ export function DriveLedgerCompactPanel({ driveId }: { driveId: string | undefin
<div className="flex flex-wrap gap-2">
<Badge variant="neutral" size="sm">
{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')}
</Badge>
<Badge variant="neutral" size="sm">
{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')}
</Badge>
{ledger.truncated ? (
<Badge variant="danger" size="sm">
Expand Down
Loading