Skip to content

Commit 6dddfe1

Browse files
committed
perf(navigation): add opt-in navigation performance instrument
Sustained turn-by-turn guidance is a power and thermal problem that unit tests cannot observe. Add a QA-only instrument so an optimization can be judged against a captured on-device baseline instead of a plausible diff. - navigationPerfMonitor: bounded, injectable controller collecting frame pacing, long tasks, MapLibre event counts, navigation-store publications and coarse resource volume. Resource URLs are classified on arrival and discarded, so no coordinates, geometry or query strings are retained. - NavPerfControl: HUD gated behind ?navperf=1. Creates no listener, observer, timer or animation frame without the flag, refreshes at most once per second, and exports JSON only on an explicit click. - NavSimControl: name the canonical measurement scenarios and show the loaded recording length. Display-only; replay timing is unchanged. - Document the device protocol, operation-count budgets, the same-device thermal release gate and the evidence-gated escalation path.
1 parent 7677ac5 commit 6dddfe1

7 files changed

Lines changed: 1609 additions & 1 deletion

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { createFakeMap, type FakeMap } from "@/test";
4+
5+
vi.mock("@/lib/MapContext", () => {
6+
const value = {
7+
mapRef: { current: null as unknown },
8+
mapReady: true,
9+
styleVersion: 0,
10+
notifyMapReady: () => {},
11+
notifyStyleReload: () => {},
12+
flyTo: () => {},
13+
fitBounds: () => {},
14+
zoomIn: () => {},
15+
zoomOut: () => {},
16+
resetBearing: () => {},
17+
};
18+
return { __test: value, useMapOptional: () => value, useMap: () => value };
19+
});
20+
21+
import { useNavigationStore } from "@openmapx/core";
22+
import * as mapContext from "@/lib/MapContext";
23+
import { NavPerfControl } from "./NavPerfControl";
24+
25+
const mapContextTest = (mapContext as unknown as { __test: { mapRef: { current: unknown } } })
26+
.__test;
27+
28+
let fake: FakeMap;
29+
let observerCount: number;
30+
let blobs: Blob[];
31+
/** Frame callbacks and intervals still outstanding — the cleanup assertions. */
32+
let liveFrames: Set<number>;
33+
let liveIntervals: Set<number>;
34+
let createObjectURL: ReturnType<typeof vi.fn>;
35+
let anchorClicks: number;
36+
let realAnchorClick: () => void;
37+
let realCreateObjectURL: typeof URL.createObjectURL;
38+
let realRevokeObjectURL: typeof URL.revokeObjectURL;
39+
40+
class StubPerformanceObserver {
41+
static supportedEntryTypes = ["longtask", "resource"];
42+
constructor(callback: () => void) {
43+
observerCount += 1;
44+
void callback;
45+
}
46+
observe() {}
47+
disconnect() {}
48+
}
49+
50+
const setQuery = (search: string) => {
51+
window.history.replaceState({}, "", search);
52+
};
53+
54+
beforeEach(() => {
55+
vi.useFakeTimers({ shouldAdvanceTime: false });
56+
observerCount = 0;
57+
blobs = [];
58+
anchorClicks = 0;
59+
liveFrames = new Set();
60+
liveIntervals = new Set();
61+
fake = createFakeMap();
62+
mapContextTest.mapRef.current = fake.map;
63+
64+
const realRequestFrame = globalThis.requestAnimationFrame;
65+
const realCancelFrame = globalThis.cancelAnimationFrame;
66+
const realSetInterval = globalThis.setInterval;
67+
const realClearInterval = globalThis.clearInterval;
68+
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
69+
const handle = realRequestFrame((t: number) => {
70+
liveFrames.delete(handle);
71+
callback(t);
72+
});
73+
liveFrames.add(handle);
74+
return handle;
75+
});
76+
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
77+
liveFrames.delete(handle);
78+
realCancelFrame(handle);
79+
});
80+
vi.stubGlobal("setInterval", (handler: TimerHandler, timeout?: number) => {
81+
const id = realSetInterval(handler as () => void, timeout) as unknown as number;
82+
liveIntervals.add(id);
83+
return id;
84+
});
85+
vi.stubGlobal("clearInterval", (id: number) => {
86+
liveIntervals.delete(id);
87+
realClearInterval(id as unknown as ReturnType<typeof setInterval>);
88+
});
89+
vi.stubGlobal("PerformanceObserver", StubPerformanceObserver);
90+
91+
// jsdom has no object-URL support; patch the two statics the export uses and
92+
// restore them afterwards (stubbing the whole URL class would break `new URL`).
93+
createObjectURL = vi.fn((...args: unknown[]) => {
94+
blobs.push(args[0] as Blob);
95+
return "blob:navperf";
96+
});
97+
realCreateObjectURL = URL.createObjectURL;
98+
realRevokeObjectURL = URL.revokeObjectURL;
99+
URL.createObjectURL = createObjectURL as unknown as typeof URL.createObjectURL;
100+
URL.revokeObjectURL = () => {};
101+
realAnchorClick = HTMLAnchorElement.prototype.click;
102+
HTMLAnchorElement.prototype.click = () => {
103+
anchorClicks += 1;
104+
};
105+
});
106+
107+
afterEach(() => {
108+
cleanup();
109+
HTMLAnchorElement.prototype.click = realAnchorClick;
110+
URL.createObjectURL = realCreateObjectURL;
111+
URL.revokeObjectURL = realRevokeObjectURL;
112+
setQuery("/");
113+
vi.unstubAllGlobals();
114+
vi.useRealTimers();
115+
vi.clearAllMocks();
116+
useNavigationStore.getState().stopNavigation();
117+
});
118+
119+
const mapHandlerCount = () =>
120+
["render", "move", "moveend", "idle"].reduce(
121+
(sum, event) => sum + (fake.state.handlers.get(event)?.size ?? 0),
122+
0,
123+
);
124+
125+
const click = (testId: string) => {
126+
act(() => {
127+
fireEvent.click(screen.getByTestId(testId));
128+
});
129+
};
130+
131+
const readout = (testId: string) => screen.getByTestId(testId).textContent ?? "";
132+
133+
describe("NavPerfControl", () => {
134+
it("renders nothing and creates no listeners, observers, timers or frames without the query flag", () => {
135+
setQuery("/");
136+
render(<NavPerfControl />);
137+
expect(screen.queryByTestId("nav-perf-control")).toBeNull();
138+
expect(mapHandlerCount()).toBe(0);
139+
expect(observerCount).toBe(0);
140+
expect(liveIntervals.size).toBe(0);
141+
expect(liveFrames.size).toBe(0);
142+
});
143+
144+
it("renders the HUD with the query flag but stays idle until started", () => {
145+
setQuery("/?navperf=1");
146+
render(<NavPerfControl />);
147+
expect(screen.queryByTestId("nav-perf-control")).not.toBeNull();
148+
expect(mapHandlerCount()).toBe(0);
149+
expect(observerCount).toBe(0);
150+
expect(liveIntervals.size).toBe(0);
151+
expect(liveFrames.size).toBe(0);
152+
});
153+
154+
it("attaches to the map and the navigation store on start, and detaches on stop", () => {
155+
setQuery("/?navperf=1");
156+
render(<NavPerfControl />);
157+
click("nav-perf-start");
158+
expect(mapHandlerCount()).toBe(4);
159+
expect(observerCount).toBe(2);
160+
expect(liveIntervals.size).toBe(1);
161+
expect(liveFrames.size).toBe(1);
162+
click("nav-perf-start");
163+
expect(mapHandlerCount()).toBe(0);
164+
expect(liveIntervals.size).toBe(0);
165+
expect(liveFrames.size).toBe(0);
166+
});
167+
168+
it("refreshes the readout at most once per second", () => {
169+
setQuery("/?navperf=1");
170+
render(<NavPerfControl />);
171+
click("nav-perf-start");
172+
expect(readout("nav-perf-map")).toContain("r0");
173+
act(() => {
174+
for (let i = 0; i < 5; i += 1) fake.emit("render");
175+
});
176+
act(() => {
177+
vi.advanceTimersByTime(900);
178+
});
179+
expect(readout("nav-perf-map")).toContain("r0");
180+
act(() => {
181+
vi.advanceTimersByTime(200);
182+
});
183+
expect(readout("nav-perf-map")).toContain("r5");
184+
});
185+
186+
it("counts navigation progress publications", () => {
187+
setQuery("/?navperf=1");
188+
render(<NavPerfControl />);
189+
click("nav-perf-start");
190+
act(() => {
191+
useNavigationStore.getState().applyProgress({
192+
alongMeters: 10,
193+
deviationMeters: 2,
194+
distanceRemaining: 100,
195+
durationRemaining: 60,
196+
etaEpochMs: 0,
197+
currentStepIndex: 0,
198+
distanceToNextManeuver: 50,
199+
snapped: [0, 0],
200+
bearing: 90,
201+
speedMps: 10,
202+
segmentIndex: 0,
203+
});
204+
});
205+
act(() => {
206+
vi.advanceTimersByTime(1100);
207+
});
208+
expect(readout("nav-perf-progress")).toContain("progress 1 of");
209+
});
210+
211+
it("resets the aggregates without detaching", () => {
212+
setQuery("/?navperf=1");
213+
render(<NavPerfControl />);
214+
click("nav-perf-start");
215+
act(() => {
216+
for (let i = 0; i < 3; i += 1) fake.emit("render");
217+
});
218+
click("nav-perf-reset");
219+
expect(readout("nav-perf-map")).toContain("r0");
220+
expect(mapHandlerCount()).toBe(4);
221+
});
222+
223+
it("exports only on an explicit click", () => {
224+
setQuery("/?navperf=1");
225+
render(<NavPerfControl />);
226+
click("nav-perf-start");
227+
act(() => {
228+
vi.advanceTimersByTime(2000);
229+
});
230+
expect(createObjectURL).toHaveBeenCalledTimes(0);
231+
click("nav-perf-export");
232+
expect(createObjectURL).toHaveBeenCalledTimes(1);
233+
expect(anchorClicks).toBe(1);
234+
});
235+
236+
it("exports aggregates and manually entered metadata, with no URLs or coordinates", async () => {
237+
setQuery("/?navperf=1");
238+
render(<NavPerfControl />);
239+
click("nav-perf-start");
240+
click("nav-perf-meta-toggle");
241+
click("nav-perf-scenario-city");
242+
const input = screen.getByTestId("nav-perf-meta-device").querySelector("input");
243+
act(() => {
244+
fireEvent.change(input as Element, { target: { value: "Pixel 7a" } });
245+
});
246+
click("nav-perf-export");
247+
248+
const text = await blobs[0].text();
249+
const parsed = JSON.parse(text) as Record<string, unknown>;
250+
expect(Object.keys(parsed)).toContain("frames");
251+
expect(Object.keys(parsed)).toContain("resources");
252+
expect(text).toContain("Pixel 7a");
253+
expect(text).toContain("city");
254+
expect(text.includes("http")).toBe(false);
255+
expect(/\d+\.\d{4,}/.test(text)).toBe(false);
256+
});
257+
258+
it("stops the monitor and clears its timer when unmounted", () => {
259+
setQuery("/?navperf=1");
260+
const view = render(<NavPerfControl />);
261+
click("nav-perf-start");
262+
expect(liveIntervals.size).toBe(1);
263+
view.unmount();
264+
expect(mapHandlerCount()).toBe(0);
265+
expect(liveIntervals.size).toBe(0);
266+
expect(liveFrames.size).toBe(0);
267+
});
268+
});

0 commit comments

Comments
 (0)