Test most of WaypointMarker.

Unlike the other map tests which heavily rely on mocks, this one uses
React refs to inspect the constructed leaflet objects. The DOM itself
doesn't appear to contain anything worth testing against (react-leaflet
rendering doesn't work like typical React rendering).

This required some infrastructure changes:

1. Forwarded ref from WaypointMarker to Marker so the test can observe
   it. Added a mergeRefs helper (and its own tests) to make that easier.
2. Switched from identity-obj-proxy to jest-transform-stub, because the
   former doesn't produce a useable image for imports, and we need
   usable images for leaflet to be able to render.

This doesn't yet test drag and drop behavior since that requires mocking
the backend, and this commit is already complicated enough. That'll be
next.
This commit is contained in:
Dan Albert 2023-06-27 22:24:37 -07:00
parent 82c234b09e
commit 374759df0f
6 changed files with 265 additions and 76 deletions

View File

@ -41,7 +41,7 @@
"electron": "^21.1.0",
"electron-is-dev": "^2.0.0",
"generate-license-file": "^2.0.0",
"identity-obj-proxy": "^3.0.0",
"jest-transform-stub": "^2.0.0",
"license-checker": "^25.0.1",
"react-scripts": "5.0.1",
"ts-node": "^10.9.1",
@ -12932,6 +12932,12 @@
"node": ">=8"
}
},
"node_modules/jest-transform-stub": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jest-transform-stub/-/jest-transform-stub-2.0.0.tgz",
"integrity": "sha512-lspHaCRx/mBbnm3h4uMMS3R5aZzMwyNpNIJLXj4cEsV0mIUtS4IjYJLSoyjRCtnxb6RIGJ4NL2quZzfIeNhbkg==",
"dev": true
},
"node_modules/jest-util": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz",
@ -30482,6 +30488,12 @@
}
}
},
"jest-transform-stub": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jest-transform-stub/-/jest-transform-stub-2.0.0.tgz",
"integrity": "sha512-lspHaCRx/mBbnm3h4uMMS3R5aZzMwyNpNIJLXj4cEsV0mIUtS4IjYJLSoyjRCtnxb6RIGJ4NL2quZzfIeNhbkg==",
"dev": true
},
"jest-util": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz",

View File

@ -69,7 +69,7 @@
"electron": "^21.1.0",
"electron-is-dev": "^2.0.0",
"generate-license-file": "^2.0.0",
"identity-obj-proxy": "^3.0.0",
"jest-transform-stub": "^2.0.0",
"license-checker": "^25.0.1",
"react-scripts": "5.0.1",
"ts-node": "^10.9.1",
@ -80,7 +80,7 @@
"node_modules/(?!(@?react-leaflet|axios)/)"
],
"moduleNameMapper": {
".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "identity-obj-proxy"
".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "jest-transform-stub"
}
}
}

View File

@ -0,0 +1,134 @@
import { renderWithProviders } from "../../testutils";
import WaypointMarker, { TOOLTIP_ZOOM_LEVEL } from "./WaypointMarker";
import { Map, Marker } from "leaflet";
import React from "react";
import { MapContainer } from "react-leaflet";
describe("WaypointMarker", () => {
it("is placed in the correct location", () => {
const waypoint = {
name: "",
position: { lat: 0, lng: 0 },
altitude_ft: 0,
altitude_reference: "MSL",
is_movable: false,
should_mark: false,
include_in_path: true,
timing: "",
};
const marker = React.createRef<Marker>();
renderWithProviders(
<MapContainer>
<WaypointMarker
number={0}
waypoint={waypoint}
flight={{
id: "",
blue: true,
sidc: "",
waypoints: [waypoint],
}}
ref={marker}
/>
</MapContainer>
);
expect(marker.current?.getLatLng()).toEqual({ lat: 0, lng: 0 });
});
it("tooltip is hidden when zoomed out", () => {
const waypoint = {
name: "",
position: { lat: 0, lng: 0 },
altitude_ft: 0,
altitude_reference: "MSL",
is_movable: false,
should_mark: false,
include_in_path: true,
timing: "",
};
const map = React.createRef<Map>();
const marker = React.createRef<Marker>();
renderWithProviders(
<MapContainer zoom={0} ref={map}>
<WaypointMarker
number={0}
waypoint={waypoint}
flight={{
id: "",
blue: true,
sidc: "",
waypoints: [waypoint],
}}
ref={marker}
/>
</MapContainer>
);
map.current?.setView({ lat: 0, lng: 0 }, TOOLTIP_ZOOM_LEVEL - 1);
expect(marker.current?.getTooltip()?.isOpen()).toBeFalsy();
});
it("tooltip is shown when zoomed in", () => {
const waypoint = {
name: "",
position: { lat: 0, lng: 0 },
altitude_ft: 0,
altitude_reference: "MSL",
is_movable: false,
should_mark: false,
include_in_path: true,
timing: "",
};
const map = React.createRef<Map>();
const marker = React.createRef<Marker>();
renderWithProviders(
<MapContainer ref={map}>
<WaypointMarker
number={0}
waypoint={waypoint}
flight={{
id: "",
blue: true,
sidc: "",
waypoints: [waypoint],
}}
ref={marker}
/>
</MapContainer>
);
map.current?.setView({ lat: 0, lng: 0 }, TOOLTIP_ZOOM_LEVEL);
expect(marker.current?.getTooltip()?.isOpen()).toBeTruthy();
});
it("tooltip has correct contents", () => {
const waypoint = {
name: "",
position: { lat: 0, lng: 0 },
altitude_ft: 25000,
altitude_reference: "MSL",
is_movable: false,
should_mark: false,
include_in_path: true,
timing: "09:00:00",
};
const map = React.createRef<Map>();
const marker = React.createRef<Marker>();
renderWithProviders(
<MapContainer ref={map}>
<WaypointMarker
number={0}
waypoint={waypoint}
flight={{
id: "",
blue: true,
sidc: "",
waypoints: [waypoint],
}}
ref={marker}
/>
</MapContainer>
);
expect(marker.current?.getTooltip()?.getContent()).toEqual(
"0 <br />25000 ft MSL<br />09:00:00"
);
});
});

View File

@ -3,13 +3,23 @@ import {
Waypoint,
useSetWaypointPositionMutation,
} from "../../api/liberationApi";
import mergeRefs from "../../mergeRefs";
import { Icon } from "leaflet";
import { Marker as LMarker } from "leaflet";
import icon from "leaflet/dist/images/marker-icon.png";
import iconShadow from "leaflet/dist/images/marker-shadow.png";
import { MutableRefObject, useCallback, useEffect, useRef } from "react";
import {
ForwardedRef,
MutableRefObject,
forwardRef,
useCallback,
useEffect,
useRef,
} from "react";
import { Marker, Tooltip, useMap, useMapEvent } from "react-leaflet";
export const TOOLTIP_ZOOM_LEVEL = 9;
const WAYPOINT_ICON = new Icon({
iconUrl: icon,
shadowUrl: iconShadow,
@ -22,7 +32,8 @@ interface WaypointMarkerProps {
flight: Flight;
}
const WaypointMarker = (props: WaypointMarkerProps) => {
const WaypointMarker = forwardRef(
(props: WaypointMarkerProps, ref: ForwardedRef<LMarker>) => {
// Most props of react-leaflet types are immutable and components will not
// update to account for changes, so we can't simply use the `permanent`
// property of the tooltip to control tooltip visibility based on the zoom
@ -36,12 +47,12 @@ const WaypointMarker = (props: WaypointMarkerProps) => {
// Instead, listen for zoom changes and rebind the tooltip when the zoom level
// changes.
const map = useMap();
const marker: MutableRefObject<LMarker | undefined> = useRef();
const marker: MutableRefObject<LMarker | null> = useRef(null);
const [putDestination] = useSetWaypointPositionMutation();
const rebindTooltip = useCallback(() => {
if (marker.current === undefined) {
if (marker.current === null) {
return;
}
@ -50,7 +61,7 @@ const WaypointMarker = (props: WaypointMarkerProps) => {
return;
}
const permanent = map.getZoom() >= 9;
const permanent = map.getZoom() >= TOOLTIP_ZOOM_LEVEL;
marker.current
.unbindTooltip()
.bindTooltip(tooltip, { permanent: permanent });
@ -61,7 +72,9 @@ const WaypointMarker = (props: WaypointMarkerProps) => {
const waypoint = props.waypoint;
marker.current?.setTooltipContent(
`${props.number} ${waypoint.name}<br />` +
`${waypoint.altitude_ft.toFixed()} ft ${waypoint.altitude_reference}<br />` +
`${waypoint.altitude_ft.toFixed()} ft ${
waypoint.altitude_reference
}<br />` +
waypoint.timing
);
});
@ -91,15 +104,12 @@ const WaypointMarker = (props: WaypointMarkerProps) => {
}
},
}}
ref={(ref) => {
if (ref != null) {
marker.current = ref;
}
}}
ref={mergeRefs(ref, marker)}
>
<Tooltip position={waypoint.position} />
</Marker>
);
};
}
);
export default WaypointMarker;

View File

@ -0,0 +1,17 @@
import mergeRefs from "./mergeRefs";
describe("mergeRefs", () => {
it("merges all kinds of refs", () => {
const referent = "foobar";
const ref = { current: null };
var callbackResult = null;
const callbackRef = (node: string | null) => {
if (node != null) {
callbackResult = node;
}
};
mergeRefs(ref, callbackRef)(referent);
expect(callbackResult).toEqual("foobar");
expect(ref.current).toEqual("foobar");
});
});

16
client/src/mergeRefs.ts Normal file
View File

@ -0,0 +1,16 @@
import { ForwardedRef } from "react";
const mergeRefs = <T extends any>(...refs: ForwardedRef<T>[]) => {
return (node: T) => {
for (const ref of refs) {
if (ref == null) {
} else if (typeof ref === "function") {
ref(node);
} else {
ref.current = node;
}
}
};
};
export default mergeRefs;