Skip to content

Commit 539276c

Browse files
Expand test coverage: WindowFovCone, WorstCaseProvider, matrix config
Frontend (249 tests, +17): - Add WindowFovCone.test.ts: 14 tests for destinationPoint geometry, sectorPoints array structure, createFovCone interface and cleanup - Export sectorPoints/destinationPoint as testable pure functions - Add MatrixConfig test for derived terrain model exclusion - Extend Leaflet mock with polygon, circleMarker, off, dragging Backend (391 tests, +8): - Add WorstCaseProvider.get_tile tests: uniform max, per-pixel max, caching behavior with mock sub-providers - Add _tile_name_for_point tests: positive/negative lat/lon, edge cases - Add KNOWN_TERRAIN includes worst_case assertion - Fix Black formatting in test_window_mode.py Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4b5fc19 commit 539276c

File tree

7 files changed

+232
-4
lines changed

7 files changed

+232
-4
lines changed

src/__tests__/components/MatrixConfig.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ describe("MatrixConfig.vue", () => {
122122
expect(wrapper.find(".badge.bg-success").text()).toBe("Saved");
123123
});
124124

125+
it("excludes derived terrain models (weighted_aggregate, worst_case)", async () => {
126+
const wrapper = await mountMatrixConfig();
127+
expect(wrapper.find("#ter-weighted_aggregate").exists()).toBe(false);
128+
expect(wrapper.find("#ter-worst_case").exists()).toBe(false);
129+
expect(wrapper.text()).not.toContain("Weighted Aggregate");
130+
expect(wrapper.text()).not.toContain("Worst Case");
131+
});
132+
125133
it("renders correct labels from shared label maps", async () => {
126134
const wrapper = await mountMatrixConfig();
127135
expect(wrapper.text()).toContain("Heltec V3");

src/__tests__/setup.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const mockMap = {
1616
addTo: vi.fn().mockReturnThis(),
1717
once: vi.fn(),
1818
on: vi.fn(),
19+
off: vi.fn(),
20+
dragging: { enable: vi.fn(), disable: vi.fn() },
1921
eachLayer: vi.fn(),
2022
getSize: vi.fn(() => ({ x: 800, y: 600 })),
2123
getPane: vi.fn(() => document.createElement("div")),
@@ -51,6 +53,15 @@ vi.mock("leaflet", () => ({
5153
extend: vi.fn(() => class {}),
5254
},
5355
Util: { setOptions: vi.fn() },
56+
polygon: vi.fn(() => ({
57+
addTo: vi.fn().mockReturnThis(),
58+
setLatLngs: vi.fn(),
59+
})),
60+
circleMarker: vi.fn(() => ({
61+
addTo: vi.fn().mockReturnThis(),
62+
setLatLng: vi.fn(),
63+
on: vi.fn(),
64+
})),
5465
polyline: vi.fn(() => ({ addTo: vi.fn(), bindPopup: vi.fn() })),
5566
easyPrint: vi.fn(() => ({ addTo: vi.fn() })),
5667
},

src/layers/WindowFovCone.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const CONE_COLOR = "#3388ff";
1111
* Build a lat/lon polygon representing a sector (pie wedge) centered
1212
* on `center`, spanning `fovDeg` degrees around `azimuthDeg` (CW from north).
1313
*/
14-
function sectorPoints(
14+
export function sectorPoints(
1515
center: L.LatLng,
1616
radiusM: number,
1717
azimuthDeg: number,
@@ -34,7 +34,7 @@ function sectorPoints(
3434
* Calculate a destination point given a start, distance (m), and bearing (degrees CW from north).
3535
* Uses spherical earth approximation.
3636
*/
37-
function destinationPoint(start: L.LatLng, distanceM: number, bearingDeg: number): L.LatLng {
37+
export function destinationPoint(start: L.LatLng, distanceM: number, bearingDeg: number): L.LatLng {
3838
const R = 6371000; // Earth radius in meters
3939
const d = distanceM / R;
4040
const brng = (bearingDeg * Math.PI) / 180;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// @vitest-environment jsdom
2+
import "../../__tests__/setup";
3+
import { describe, it, expect, vi } from "vitest";
4+
import { destinationPoint, sectorPoints, createFovCone } from "../WindowFovCone";
5+
import L from "leaflet";
6+
7+
describe("destinationPoint", () => {
8+
it("bearing 0 (north) increases latitude", () => {
9+
const start = L.latLng(45, -75);
10+
const result = destinationPoint(start, 1000, 0);
11+
expect(result.lat).toBeGreaterThan(45);
12+
expect(result.lng).toBeCloseTo(-75, 4);
13+
});
14+
15+
it("bearing 180 (south) decreases latitude", () => {
16+
const start = L.latLng(45, -75);
17+
const result = destinationPoint(start, 1000, 180);
18+
expect(result.lat).toBeLessThan(45);
19+
expect(result.lng).toBeCloseTo(-75, 4);
20+
});
21+
22+
it("bearing 90 (east) increases longitude", () => {
23+
const start = L.latLng(45, -75);
24+
const result = destinationPoint(start, 1000, 90);
25+
expect(result.lat).toBeCloseTo(45, 4);
26+
expect(result.lng).toBeGreaterThan(-75);
27+
});
28+
29+
it("bearing 270 (west) decreases longitude", () => {
30+
const start = L.latLng(45, -75);
31+
const result = destinationPoint(start, 1000, 270);
32+
expect(result.lat).toBeCloseTo(45, 4);
33+
expect(result.lng).toBeLessThan(-75);
34+
});
35+
36+
it("distance 0 returns the same point", () => {
37+
const start = L.latLng(45, -75);
38+
const result = destinationPoint(start, 0, 42);
39+
expect(result.lat).toBeCloseTo(45, 10);
40+
expect(result.lng).toBeCloseTo(-75, 10);
41+
});
42+
43+
it("1000m displacement is approximately correct", () => {
44+
const start = L.latLng(0, 0);
45+
const result = destinationPoint(start, 1000, 0);
46+
// 1000m north at the equator is ~0.009 degrees latitude
47+
const expectedDeg = (1000 / 6371000) * (180 / Math.PI);
48+
expect(result.lat).toBeCloseTo(expectedDeg, 4);
49+
});
50+
});
51+
52+
describe("sectorPoints", () => {
53+
it("returns array starting and ending with center", () => {
54+
const center = L.latLng(45, -75);
55+
const points = sectorPoints(center, 500, 0, 90, 10);
56+
expect(points[0]).toBe(center);
57+
expect(points[points.length - 1]).toBe(center);
58+
});
59+
60+
it("returns segments + 3 points (center + segments+1 arc + center)", () => {
61+
const center = L.latLng(45, -75);
62+
const segments = 10;
63+
const points = sectorPoints(center, 500, 0, 90, segments);
64+
// center + (segments + 1) arc points + center = segments + 3
65+
expect(points).toHaveLength(segments + 3);
66+
});
67+
68+
it("arc points are all different from center", () => {
69+
const center = L.latLng(45, -75);
70+
const points = sectorPoints(center, 500, 0, 90, 4);
71+
// Points 1 through length-2 are arc points (not center)
72+
for (let i = 1; i < points.length - 1; i++) {
73+
expect(points[i]).not.toBe(center);
74+
}
75+
});
76+
77+
it("single segment produces 4 points", () => {
78+
const center = L.latLng(45, -75);
79+
const points = sectorPoints(center, 500, 0, 90, 1);
80+
// center + 2 arc endpoints + center
81+
expect(points).toHaveLength(4);
82+
});
83+
});
84+
85+
describe("createFovCone", () => {
86+
it("returns an object with update and remove methods", () => {
87+
const map = L.map(document.createElement("div"));
88+
const handle = createFovCone(map, [45, -75], 0, 90, vi.fn());
89+
expect(typeof handle.update).toBe("function");
90+
expect(typeof handle.remove).toBe("function");
91+
});
92+
93+
it("remove() calls map.removeLayer twice (polygon + handle)", () => {
94+
const map = L.map(document.createElement("div"));
95+
const handle = createFovCone(map, [45, -75], 0, 90, vi.fn());
96+
handle.remove();
97+
expect(map.removeLayer).toHaveBeenCalledTimes(2);
98+
});
99+
100+
it("remove() calls map.off to clean up event listeners", () => {
101+
const map = L.map(document.createElement("div"));
102+
const handle = createFovCone(map, [45, -75], 0, 90, vi.fn());
103+
handle.remove();
104+
expect(map.off).toHaveBeenCalledWith("mousemove", expect.any(Function));
105+
expect(map.off).toHaveBeenCalledWith("mouseup", expect.any(Function));
106+
});
107+
108+
it("creates polygon and circleMarker via Leaflet", () => {
109+
const map = L.map(document.createElement("div"));
110+
createFovCone(map, [45, -75], 0, 90, vi.fn());
111+
expect(L.polygon).toHaveBeenCalled();
112+
expect(L.circleMarker).toHaveBeenCalled();
113+
});
114+
});

tests/test_matrix.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
from app.db import db_session
44
from app.matrix import (
55
DEFAULT_MATRIX_CONFIG,
6+
KNOWN_TERRAIN,
67
get_matrix_combinations,
78
get_matrix_config,
89
set_matrix_config,
910
)
1011
from app.models.MatrixConfigRequest import MatrixConfigRequest
1112

1213

14+
class TestKnownTerrain:
15+
def test_known_terrain_includes_worst_case(self):
16+
assert "worst_case" in KNOWN_TERRAIN
17+
18+
1319
class TestDefaultMatrixConfig:
1420
def test_has_expected_fields(self):
1521
assert DEFAULT_MATRIX_CONFIG.hardware == ["v3", "v4"]

tests/test_window_mode.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Tests for window mode — directional attenuation through glass/structure."""
22

3-
43
import numpy as np
54
import pytest
65
from pydantic import ValidationError

tests/test_worst_case.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import gzip
44
import io
5+
from unittest.mock import MagicMock
56

67
import numpy as np
78
import pytest
89

910
from app.models.CoveragePredictionRequest import CoveragePredictionRequest
10-
from app.services.terrain import _HGT_SIZE, TerrainProvider
11+
from app.services.splat import Splat
12+
from app.services.terrain import _HGT_SIZE, TerrainProvider, WorstCaseProvider
1113

1214

1315
def _make_hgt_gz(value: int, side: int = _HGT_SIZE) -> bytes:
@@ -130,3 +132,91 @@ def test_floor_at_one_meter(self):
130132
adjusted = max(1.0, 5.0 - delta)
131133
assert delta == 100.0
132134
assert adjusted == 1.0
135+
136+
137+
SMALL_SIDE = 5
138+
139+
140+
class TestWorstCaseProviderGetTile:
141+
"""Test WorstCaseProvider.get_tile() returns per-pixel max of three sub-providers."""
142+
143+
def _build_provider(self, srtm_data: bytes, dsm_data: bytes, lulc_data: bytes) -> WorstCaseProvider:
144+
srtm_mock = MagicMock()
145+
srtm_mock.get_tile.return_value = srtm_data
146+
dsm_mock = MagicMock()
147+
dsm_mock.get_tile.return_value = dsm_data
148+
lulc_mock = MagicMock()
149+
lulc_mock.get_tile.return_value = lulc_data
150+
cache: dict = {}
151+
s3_mock = MagicMock()
152+
provider = WorstCaseProvider.__new__(WorstCaseProvider)
153+
provider.s3 = s3_mock
154+
provider.tile_cache = cache
155+
provider._srtm = srtm_mock
156+
provider._dsm = dsm_mock
157+
provider._lulc = lulc_mock
158+
return provider
159+
160+
def test_uniform_tiles_returns_max(self):
161+
"""When all tiles are uniform, result equals the highest value."""
162+
srtm = _make_hgt_gz(100, side=SMALL_SIDE)
163+
dsm = _make_hgt_gz(150, side=SMALL_SIDE)
164+
lulc = _make_hgt_gz(120, side=SMALL_SIDE)
165+
provider = self._build_provider(srtm, dsm, lulc)
166+
167+
result_gz = provider.get_tile("N45W075.hgt.gz")
168+
result_arr = TerrainProvider._decompress_hgt(result_gz)
169+
170+
np.testing.assert_array_equal(result_arr, 150)
171+
172+
def test_varying_sources_per_pixel_max(self):
173+
"""When different sources dominate at different pixels, result is per-pixel max."""
174+
srtm_arr = np.array([[200, 50, 10], [10, 10, 10], [10, 10, 10]], dtype=np.int16)
175+
dsm_arr = np.array([[10, 300, 10], [10, 10, 10], [10, 10, 10]], dtype=np.int16)
176+
lulc_arr = np.array([[10, 10, 400], [10, 10, 10], [10, 10, 10]], dtype=np.int16)
177+
provider = self._build_provider(
178+
_make_hgt_gz_from_array(srtm_arr),
179+
_make_hgt_gz_from_array(dsm_arr),
180+
_make_hgt_gz_from_array(lulc_arr),
181+
)
182+
183+
result_gz = provider.get_tile("N45W075.hgt.gz")
184+
result_arr = TerrainProvider._decompress_hgt(result_gz)
185+
186+
assert result_arr[0, 0] == 200
187+
assert result_arr[0, 1] == 300
188+
assert result_arr[0, 2] == 400
189+
# All remaining pixels should be 10 (all sources equal)
190+
np.testing.assert_array_equal(result_arr[1:, :], 10)
191+
192+
def test_result_is_cached(self):
193+
"""After the first call, subsequent calls return from cache without re-fetching."""
194+
srtm = _make_hgt_gz(100, side=SMALL_SIDE)
195+
dsm = _make_hgt_gz(200, side=SMALL_SIDE)
196+
lulc = _make_hgt_gz(150, side=SMALL_SIDE)
197+
provider = self._build_provider(srtm, dsm, lulc)
198+
199+
first = provider.get_tile("test_tile.hgt.gz")
200+
second = provider.get_tile("test_tile.hgt.gz")
201+
202+
assert first == second
203+
# Sub-providers should only have been called once each
204+
provider._srtm.get_tile.assert_called_once()
205+
provider._dsm.get_tile.assert_called_once()
206+
provider._lulc.get_tile.assert_called_once()
207+
208+
209+
class TestTileNameForPoint:
210+
"""Test Splat._tile_name_for_point() static method."""
211+
212+
def test_positive_lat_negative_lon(self):
213+
assert Splat._tile_name_for_point(45.5, -74.3) == "N45W075.hgt.gz"
214+
215+
def test_negative_lat_positive_lon(self):
216+
assert Splat._tile_name_for_point(-12.3, 34.7) == "S13E034.hgt.gz"
217+
218+
def test_near_zero_positive(self):
219+
assert Splat._tile_name_for_point(0.5, 0.5) == "N00E000.hgt.gz"
220+
221+
def test_exact_negative_lon(self):
222+
assert Splat._tile_name_for_point(40.0, -105.0) == "N40W106.hgt.gz"

0 commit comments

Comments
 (0)