Skip to content

Commit 5cbdca1

Browse files
committed
Added Vitest for testing in both API and web applications, updated package.json files, and enhanced utils with new geographic functions.
1 parent 1a4549b commit 5cbdca1

File tree

10 files changed

+1036
-11
lines changed

10 files changed

+1036
-11
lines changed

apps/api/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"deploy": "wrangler deploy --minify",
66
"lint": "eslint . --ext .ts",
77
"lint:fix": "eslint . --ext .ts --fix",
8+
"test": "vitest",
9+
"test:run": "vitest run",
810
"format": "prettier --write \"**/*.{ts,js,json,md}\"",
911
"format:check": "prettier --check \"**/*.{ts,js,json,md}\""
1012
},
@@ -21,6 +23,7 @@
2123
"devDependencies": {
2224
"@cloudflare/workers-types": "^4.20250109.0",
2325
"prettier": "^3.5.0",
26+
"vitest": "^3.2.4",
2427
"wrangler": "^4.14.4"
2528
}
2629
}

apps/api/src/utils/utils.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { lonLatToTile, type BoundingRegion, degToRad } from './utils'
3+
4+
describe('lonLatToTile', () => {
5+
// Define a test root region covering a 2x2 degree area in radians
6+
const testRoot: BoundingRegion = {
7+
west: degToRad(-1), // -1 degree
8+
south: degToRad(-1), // -1 degree
9+
east: degToRad(1), // 1 degree
10+
north: degToRad(1), // 1 degree
11+
minH: 0,
12+
maxH: 100
13+
}
14+
15+
describe('level 0 (single tile)', () => {
16+
it('should return {x: 0, y: 0} for any point inside the root region', () => {
17+
expect(lonLatToTile(testRoot, 0, degToRad(0), degToRad(0))).toEqual({ x: 0, y: 0 })
18+
expect(lonLatToTile(testRoot, 0, degToRad(-0.5), degToRad(0.5))).toEqual({ x: 0, y: 0 })
19+
expect(lonLatToTile(testRoot, 0, degToRad(0.9), degToRad(-0.9))).toEqual({ x: 0, y: 0 })
20+
})
21+
})
22+
23+
describe('level 1 (2x2 grid)', () => {
24+
it('should return correct tile coordinates for each quadrant', () => {
25+
// Bottom-left quadrant (x=0, y=0)
26+
expect(lonLatToTile(testRoot, 1, degToRad(-0.5), degToRad(-0.5))).toEqual({ x: 0, y: 0 })
27+
28+
// Bottom-right quadrant (x=1, y=0)
29+
expect(lonLatToTile(testRoot, 1, degToRad(0.5), degToRad(-0.5))).toEqual({ x: 1, y: 0 })
30+
31+
// Top-left quadrant (x=0, y=1)
32+
expect(lonLatToTile(testRoot, 1, degToRad(-0.5), degToRad(0.5))).toEqual({ x: 0, y: 1 })
33+
34+
// Top-right quadrant (x=1, y=1)
35+
expect(lonLatToTile(testRoot, 1, degToRad(0.5), degToRad(0.5))).toEqual({ x: 1, y: 1 })
36+
})
37+
38+
it('should handle coordinates at tile boundaries correctly', () => {
39+
// Point exactly at the center should go to bottom-left tile
40+
expect(lonLatToTile(testRoot, 1, degToRad(0), degToRad(0))).toEqual({ x: 1, y: 1 })
41+
42+
// Point at west boundary should go to left tiles
43+
expect(lonLatToTile(testRoot, 1, degToRad(-1), degToRad(-0.5))).toEqual({ x: 0, y: 0 })
44+
expect(lonLatToTile(testRoot, 1, degToRad(-1), degToRad(0.5))).toEqual({ x: 0, y: 1 })
45+
})
46+
})
47+
48+
describe('level 2 (4x4 grid)', () => {
49+
it('should return correct tile coordinates for higher subdivision', () => {
50+
// Test a point in the first tile (0,0)
51+
expect(lonLatToTile(testRoot, 2, degToRad(-0.75), degToRad(-0.75))).toEqual({ x: 0, y: 0 })
52+
53+
// Test a point in the last tile (3,3)
54+
expect(lonLatToTile(testRoot, 2, degToRad(0.75), degToRad(0.75))).toEqual({ x: 3, y: 3 })
55+
56+
// Test center point
57+
expect(lonLatToTile(testRoot, 2, degToRad(0), degToRad(0))).toEqual({ x: 2, y: 2 })
58+
})
59+
})
60+
61+
describe('boundary conditions', () => {
62+
it('should handle coordinates at exact region boundaries', () => {
63+
// Points at the exact boundaries of the root region
64+
expect(lonLatToTile(testRoot, 1, testRoot.west, testRoot.south)).toEqual({ x: 0, y: 0 })
65+
expect(lonLatToTile(testRoot, 1, testRoot.east, testRoot.north)).toEqual({ x: 1, y: 1 })
66+
67+
// Points very close to but within boundaries
68+
const epsilon = 1e-10
69+
expect(lonLatToTile(testRoot, 1, testRoot.west + epsilon, testRoot.south + epsilon)).toEqual({ x: 0, y: 0 })
70+
})
71+
72+
it('should clamp to maximum tile indices for edge coordinates', () => {
73+
// At level 2, max indices should be 3,3
74+
expect(lonLatToTile(testRoot, 2, testRoot.east, testRoot.north)).toEqual({ x: 3, y: 3 })
75+
})
76+
})
77+
78+
describe('error conditions', () => {
79+
it('should throw RangeError for coordinates outside west boundary', () => {
80+
expect(() => {
81+
lonLatToTile(testRoot, 1, degToRad(-1.1), degToRad(0))
82+
}).toThrow(RangeError)
83+
expect(() => {
84+
lonLatToTile(testRoot, 1, degToRad(-1.1), degToRad(0))
85+
}).toThrow('Position is outside the root bounding region')
86+
})
87+
88+
it('should throw RangeError for coordinates outside east boundary', () => {
89+
expect(() => {
90+
lonLatToTile(testRoot, 1, degToRad(1.1), degToRad(0))
91+
}).toThrow(RangeError)
92+
})
93+
94+
it('should throw RangeError for coordinates outside south boundary', () => {
95+
expect(() => {
96+
lonLatToTile(testRoot, 1, degToRad(0), degToRad(-1.1))
97+
}).toThrow(RangeError)
98+
})
99+
100+
it('should throw RangeError for coordinates outside north boundary', () => {
101+
expect(() => {
102+
lonLatToTile(testRoot, 1, degToRad(0), degToRad(1.1))
103+
}).toThrow(RangeError)
104+
})
105+
})
106+
107+
describe('real-world coordinate system', () => {
108+
// Test with a more realistic bounding region (Switzerland area)
109+
const swissRegion: BoundingRegion = {
110+
west: degToRad(5.96), // Western Switzerland
111+
south: degToRad(45.82), // Southern Switzerland
112+
east: degToRad(10.49), // Eastern Switzerland
113+
north: degToRad(47.81), // Northern Switzerland
114+
minH: 0,
115+
maxH: 4000
116+
}
117+
118+
it('should handle realistic geographic coordinates', () => {
119+
// Test Bern coordinates (approximately 7.45°E, 46.95°N)
120+
const bernLon = degToRad(7.45)
121+
const bernLat = degToRad(46.95)
122+
123+
const result = lonLatToTile(swissRegion, 3, bernLon, bernLat)
124+
125+
// Verify the result is within expected bounds for level 3 (8x8 grid)
126+
expect(result.x).toBeGreaterThanOrEqual(0)
127+
expect(result.x).toBeLessThanOrEqual(7)
128+
expect(result.y).toBeGreaterThanOrEqual(0)
129+
expect(result.y).toBeLessThanOrEqual(7)
130+
131+
// Verify it's roughly in the center of Switzerland (should be around middle tiles)
132+
// Bern is roughly in the center-west of Switzerland, so expect x around 2-3 and y around 4-5
133+
expect(result.x).toBeGreaterThanOrEqual(2)
134+
expect(result.x).toBeLessThanOrEqual(4)
135+
expect(result.y).toBeGreaterThanOrEqual(3)
136+
expect(result.y).toBeLessThanOrEqual(6)
137+
})
138+
139+
it('should handle Lausanne dataset coordinates', () => {
140+
// Lausanne dataset bounds
141+
const lausanneRegion: BoundingRegion = {
142+
west: degToRad(6.5248166), // Western Lausanne
143+
south: degToRad(46.4976347), // Southern Lausanne
144+
east: degToRad(6.6700634), // Eastern Lausanne
145+
north: degToRad(46.6156402), // Northern Lausanne
146+
minH: 0,
147+
maxH: 1000
148+
}
149+
150+
// Test coordinates in central Lausanne (approximately 6.63°E, 46.52°N)
151+
const lausanneLon = degToRad(6.63)
152+
const lausanneLat = degToRad(46.52)
153+
154+
const result = lonLatToTile(lausanneRegion, 4, lausanneLon, lausanneLat)
155+
156+
// Verify the result is within expected bounds for level 4 (16x16 grid)
157+
expect(result.x).toBeGreaterThanOrEqual(0)
158+
expect(result.x).toBeLessThanOrEqual(15)
159+
expect(result.y).toBeGreaterThanOrEqual(0)
160+
expect(result.y).toBeLessThanOrEqual(15)
161+
162+
// For a small region like Lausanne, the coordinates should be in the eastern part
163+
// since 6.63 is closer to the eastern bound (6.67) than western (6.52)
164+
expect(result.x).toBeGreaterThan(8) // Should be in the eastern half
165+
expect(result.y).toBeGreaterThanOrEqual(0) // Should be in the valid range
166+
})
167+
168+
it('should handle coordinates at Lausanne dataset boundaries', () => {
169+
const lausanneRegion: BoundingRegion = {
170+
west: degToRad(6.5248166),
171+
south: degToRad(46.4976347),
172+
east: degToRad(6.6700634),
173+
north: degToRad(46.6156402),
174+
minH: 0,
175+
maxH: 1000
176+
}
177+
178+
// Test boundary coordinates
179+
expect(() => {
180+
lonLatToTile(lausanneRegion, 2, lausanneRegion.west, lausanneRegion.south)
181+
}).not.toThrow()
182+
183+
expect(() => {
184+
lonLatToTile(lausanneRegion, 2, lausanneRegion.east, lausanneRegion.north)
185+
}).not.toThrow()
186+
187+
// Test coordinates just outside the boundaries
188+
expect(() => {
189+
lonLatToTile(lausanneRegion, 2, degToRad(6.5), degToRad(46.5))
190+
}).toThrow(RangeError)
191+
192+
expect(() => {
193+
lonLatToTile(lausanneRegion, 2, degToRad(6.68), degToRad(46.62))
194+
}).toThrow(RangeError)
195+
})
196+
})
197+
})

apps/api/src/utils/utils.ts

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,96 @@ proj4.defs(
1616
* @param y LV95 y coordinate
1717
* @returns WGS84 coordinates
1818
*/
19-
const LV95toWGS84 = (x: number, y: number) => {
19+
export function LV95toWGS84(x: number, y: number) {
2020
return proj4('LV95', 'WGS84', [x, y]);
21-
};
21+
}
2222

2323
/**
2424
* Convert degrees to radians
2525
* @param deg Degrees
2626
* @returns Radians
2727
*/
28-
const degToRad = (deg: number) => {
28+
export function degToRad(deg: number) {
2929
return deg * (Math.PI / 180);
30-
};
30+
}
3131

32-
export { LV95toWGS84, degToRad };
32+
/** A geographic bounding region as used by 3D Tiles 1.1 (radians + metres). */
33+
export interface BoundingRegion {
34+
west: number; // longitude, radians
35+
south: number; // latitude, radians
36+
east: number; // longitude, radians
37+
north: number; // latitude, radians
38+
minH: number; // metres
39+
maxH: number; // metres
40+
}
41+
42+
/**
43+
* Compute the bounding region of a quadtree tile.
44+
*
45+
* @param root Full-extent region for level 0 (usually the implicit-root tile).
46+
* @param level 0-based quadtree level.
47+
* @param x,y Horizontal tile indices in that level (0 ≤ index < 2^level).
48+
* @param splitH If true, height is halved each level; otherwise height stays constant.
49+
*/
50+
export function tileToRegion(
51+
root: BoundingRegion,
52+
level: number,
53+
x: number,
54+
y: number,
55+
splitH = false
56+
): BoundingRegion {
57+
const div = 1 << level; // 2^level
58+
const lonStep = (root.east - root.west) / div; // Δλ per tile
59+
const latStep = (root.north - root.south) / div; // Δφ per tile
60+
61+
const west = root.west + lonStep * x; // west → east is +λ :contentReference[oaicite:1]{index=1}
62+
const east = west + lonStep;
63+
const south = root.south + latStep * y; // south → north is +φ :contentReference[oaicite:2]{index=2}
64+
const north = south + latStep;
65+
66+
let { minH, maxH } = root;
67+
if (splitH && level > 0) {
68+
const hStep = (root.maxH - root.minH) / div; // follow same rule for z (height)
69+
minH = root.minH + hStep * y; // use y as proxy for z-slice; adapt if you store z
70+
maxH = minH + hStep;
71+
}
72+
return { west, south, east, north, minH, maxH };
73+
}
74+
75+
/**
76+
* Return the (x, y) tile coordinates that contain the given longitude/latitude.
77+
*
78+
* @param root The bounding region of the implicit-root tile.
79+
* @param level Quadtree level (0 = root).
80+
* @param lon Longitude in **radians** (WGS 84, same units as the spec).
81+
* @param lat Latitude in **radians**.
82+
*
83+
* @throws RangeError if the point lies outside the root region.
84+
*/
85+
export function lonLatToTile(
86+
root: BoundingRegion,
87+
level: number,
88+
lon: number,
89+
lat: number,
90+
): { x: number; y: number } {
91+
if (lon < root.west || lon > root.east ||
92+
lat < root.south || lat > root.north) {
93+
throw new RangeError("Position is outside the root bounding region");
94+
}
95+
96+
const div = 1 << level; // = 2^level
97+
const sizeX = (root.east - root.west) / div; // Δλ per tile
98+
const sizeY = (root.north - root.south) / div; // Δφ per tile
99+
100+
// Compute zero-based indices (west→east, south→north) :contentReference[oaicite:0]{index=0}
101+
const x = Math.min(
102+
div - 1,
103+
Math.floor((lon - root.west) / sizeX),
104+
);
105+
const y = Math.min(
106+
div - 1,
107+
Math.floor((lat - root.south) / sizeY),
108+
);
109+
110+
return { x, y };
111+
}

apps/api/vitest.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
export default defineConfig({
4+
test: {
5+
globals: true,
6+
environment: 'node',
7+
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts}'],
8+
exclude: ['**/node_modules/**', '**/dist/**'],
9+
},
10+
})

apps/web/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,20 @@
1010
"preview": "vite preview",
1111
"lint": "eslint . --ext .ts,.tsx",
1212
"lint:fix": "eslint . --ext .ts,.tsx --fix",
13+
"test": "vitest",
14+
"test:run": "vitest run",
1315
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
1416
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
1517
},
1618
"devDependencies": {
19+
"@types/jsdom": "^21.1.7",
1720
"@types/node": "^22.13.11",
1821
"@types/three": "^0.173.0",
22+
"jsdom": "^26.1.0",
1923
"prettier": "^3.5.0",
2024
"typescript": "~5.7.2",
2125
"vite": "^6.1.0",
26+
"vitest": "^3.2.4",
2227
"wrangler": "4"
2328
},
2429
"dependencies": {

apps/web/src/main.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
describe('Main', () => {
4+
it('should be able to run tests', () => {
5+
expect(1 + 1).toBe(2)
6+
})
7+
8+
it('should have access to DOM', () => {
9+
const div = document.createElement('div')
10+
div.textContent = 'Hello World'
11+
expect(div.textContent).toBe('Hello World')
12+
})
13+
})

apps/web/vitest.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
export default defineConfig({
4+
test: {
5+
globals: true,
6+
environment: 'jsdom',
7+
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
8+
exclude: ['**/node_modules/**', '**/dist/**'],
9+
},
10+
})

0 commit comments

Comments
 (0)