Files
vngeoguessr/tests/game.test.js
T
tiennm99 c2c1ced387 test: cover the lib layer against a fake and a real Redis
Scoring, the Upstash key adapter, sessions and the leaderboards had no
coverage, and they carry the anti-cheat model: server-held coordinates,
session TTLs, rank arithmetic.

One suite runs against two backing stores. By default @upstash/redis is
mocked with an in-memory fake, so `npm test` needs no service and
finishes well under a second. With TEST_REDIS=real the mock steps aside
and the same files run against a real Redis behind SRH, the proxy
Upstash recommends for local work, since the SDK speaks HTTP REST rather
than RESP and a plain Redis container cannot serve it alone.

Running both is what keeps the fake honest: a semantic it gets wrong
shows up as a green unit run and a red integration run. Two tests skip
against real Redis, one asserting a response shape only an older SDK
produces and one fast-forwarding half an hour to watch a session expire.

The suite was checked by mutation rather than by passing alone. Breaking
a score band, the key prefix and the session TTL each failed a test;
reversing the global trim window did not, because only the city
leaderboard was asserted, so that test now covers both scopes.

The compose stack doubles as a local Redis for `npm run dev`, which had
no credentials to reach one before.

package.json also loses the eight unused runtime dependencies removed in
the cleanup that follows; they cannot be split from the same file.
2026-08-30 09:26:02 +07:00

109 lines
3.2 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import {
CITIES,
cities,
cityDeltas,
calculateDistance,
calculateScore,
formatDistance,
} from '../src/lib/game.js';
describe('calculateScore', () => {
// The bands are the whole scoring rule, so each boundary is pinned on both
// sides: an off-by-one here silently rewrites what players are rewarded for.
it.each([
[0, 5],
[50, 5],
[51, 4],
[100, 4],
[101, 3],
[200, 3],
[201, 2],
[500, 2],
[501, 1],
[1000, 1],
[1001, 0],
[50000, 0],
])('scores %dm as %d', (distance, expected) => {
expect(calculateScore(distance)).toBe(expected);
});
});
describe('calculateDistance', () => {
it('returns zero for identical coordinates', () => {
expect(calculateDistance(10.8231, 106.6297, 10.8231, 106.6297)).toBe(0);
});
it('measures one degree of latitude as roughly 111km', () => {
const metres = calculateDistance(10, 106, 11, 106);
// Turf uses a spherical earth, so the exact figure depends on its radius
// constant. Assert the magnitude, not the constant.
expect(metres).toBeGreaterThan(110_000);
expect(metres).toBeLessThan(112_000);
});
it('is symmetric', () => {
const there = calculateDistance(10.8231, 106.6297, 21.0285, 105.8542);
const back = calculateDistance(21.0285, 105.8542, 10.8231, 106.6297);
expect(there).toBe(back);
});
it('returns whole metres', () => {
const metres = calculateDistance(10.8231, 106.6297, 10.8241, 106.6307);
expect(Number.isInteger(metres)).toBe(true);
});
it('grows with separation', () => {
const near = calculateDistance(10.82, 106.62, 10.83, 106.62);
const far = calculateDistance(10.82, 106.62, 10.9, 106.62);
expect(far).toBeGreaterThan(near);
});
});
describe('formatDistance', () => {
it.each([
[0, '0m'],
[999, '999m'],
[1000, '1.00km'],
[1500, '1.50km'],
[12300, '12.30km'],
])('formats %dm as %s', (distance, expected) => {
expect(formatDistance(distance)).toBe(expected);
});
});
describe('city configuration', () => {
it('exposes only enabled cities to the UI', () => {
const exposed = cities.map((city) => city.code).sort();
const expected = Object.values(CITIES)
.filter((city) => city.enabled)
.map((city) => city.code)
.sort();
expect(exposed).toEqual(expected);
});
it('uppercases city names for the UI', () => {
for (const city of cities) {
expect(city.name).toBe(city.name.toUpperCase());
}
});
it('gives every city a Mapillary delta and a well-formed bbox', () => {
for (const city of Object.values(CITIES)) {
expect(cityDeltas[city.code]).toBeGreaterThan(0);
const [minLng, minLat, maxLng, maxLat] = city.bbox;
expect(maxLng).toBeGreaterThan(minLng);
expect(maxLat).toBeGreaterThan(minLat);
// The centre must sit inside the box it labels, or the map opens on one
// city while the panorama comes from another.
const [centreLat, centreLng] = city.center;
expect(centreLat).toBeGreaterThanOrEqual(minLat);
expect(centreLat).toBeLessThanOrEqual(maxLat);
expect(centreLng).toBeGreaterThanOrEqual(minLng);
expect(centreLng).toBeLessThanOrEqual(maxLng);
}
});
});