Add progressive web app companion for cross-platform access
Vite + TypeScript PWA that mirrors the Android app's core features: - Pre-processed shelter data (build-time UTM33N→WGS84 conversion) - Leaflet map with shelter markers, user location, and offline tiles - Canvas compass arrow (ported from DirectionArrowView.kt) - IndexedDB shelter cache with 7-day staleness check - Service worker with CacheFirst tiles and precached app shell - i18n for en, nb, nn (ported from Android strings.xml) - iOS/Android compass handling with low-pass filter - Respects user map interaction (no auto-snap on pan/zoom) - Build revision cache-breaker for reliable SW updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
46365b713b
commit
e8428de775
12051 changed files with 1799735 additions and 0 deletions
67
pwa/tests/distance-utils.test.ts
Normal file
67
pwa/tests/distance-utils.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
distanceMeters,
|
||||
bearingDegrees,
|
||||
formatDistance,
|
||||
} from '../src/util/distance-utils';
|
||||
|
||||
describe('distanceMeters', () => {
|
||||
it('returns 0 for same point', () => {
|
||||
expect(distanceMeters(59.9, 10.7, 59.9, 10.7)).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates Oslo to Bergen distance (~305km)', () => {
|
||||
// Oslo: 59.9139, 10.7522 — Bergen: 60.3913, 5.3221
|
||||
const d = distanceMeters(59.9139, 10.7522, 60.3913, 5.3221);
|
||||
expect(d).toBeGreaterThan(300_000);
|
||||
expect(d).toBeLessThan(310_000);
|
||||
});
|
||||
|
||||
it('calculates short distance (~1.1km)', () => {
|
||||
// ~1km apart in Oslo
|
||||
const d = distanceMeters(59.91, 10.75, 59.92, 10.75);
|
||||
expect(d).toBeGreaterThan(1000);
|
||||
expect(d).toBeLessThan(1200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearingDegrees', () => {
|
||||
it('north bearing is ~0', () => {
|
||||
const b = bearingDegrees(59.9, 10.7, 60.9, 10.7);
|
||||
expect(b).toBeCloseTo(0, 0);
|
||||
});
|
||||
|
||||
it('east bearing is ~90', () => {
|
||||
const b = bearingDegrees(59.9, 10.0, 59.9, 11.0);
|
||||
expect(b).toBeGreaterThan(85);
|
||||
expect(b).toBeLessThan(95);
|
||||
});
|
||||
|
||||
it('south bearing is ~180', () => {
|
||||
const b = bearingDegrees(60.0, 10.7, 59.0, 10.7);
|
||||
expect(b).toBeCloseTo(180, 0);
|
||||
});
|
||||
|
||||
it('west bearing is ~270', () => {
|
||||
const b = bearingDegrees(59.9, 11.0, 59.9, 10.0);
|
||||
expect(b).toBeGreaterThan(265);
|
||||
expect(b).toBeLessThan(275);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDistance', () => {
|
||||
it('formats short distance in meters', () => {
|
||||
expect(formatDistance(42)).toBe('42 m');
|
||||
expect(formatDistance(999)).toBe('999 m');
|
||||
});
|
||||
|
||||
it('formats long distance in km', () => {
|
||||
expect(formatDistance(1000)).toBe('1.0 km');
|
||||
expect(formatDistance(1500)).toBe('1.5 km');
|
||||
expect(formatDistance(12345)).toBe('12.3 km');
|
||||
});
|
||||
|
||||
it('rounds meters to nearest integer', () => {
|
||||
expect(formatDistance(42.7)).toBe('43 m');
|
||||
});
|
||||
});
|
||||
64
pwa/tests/i18n.test.ts
Normal file
64
pwa/tests/i18n.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Must mock navigator.languages before importing i18n
|
||||
const mockLanguages = { value: ['en'] };
|
||||
Object.defineProperty(globalThis.navigator, 'languages', {
|
||||
get: () => mockLanguages.value,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
describe('i18n', () => {
|
||||
beforeEach(async () => {
|
||||
// Re-import to reset state
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('returns English strings by default', async () => {
|
||||
mockLanguages.value = ['en'];
|
||||
const { initLocale, t } = await import('../src/i18n/i18n');
|
||||
initLocale();
|
||||
expect(t('app_name')).toBe('Tilfluktsrom');
|
||||
expect(t('status_ready')).toBe('Ready');
|
||||
});
|
||||
|
||||
it('detects Norwegian Bokmål', async () => {
|
||||
mockLanguages.value = ['nb-NO'];
|
||||
const { initLocale, t } = await import('../src/i18n/i18n');
|
||||
initLocale();
|
||||
expect(t('status_ready')).toBe('Klar');
|
||||
});
|
||||
|
||||
it('detects "no" as Bokmål', async () => {
|
||||
mockLanguages.value = ['no'];
|
||||
const { initLocale, t } = await import('../src/i18n/i18n');
|
||||
initLocale();
|
||||
expect(t('status_ready')).toBe('Klar');
|
||||
});
|
||||
|
||||
it('detects Nynorsk', async () => {
|
||||
mockLanguages.value = ['nn'];
|
||||
const { initLocale, t } = await import('../src/i18n/i18n');
|
||||
initLocale();
|
||||
expect(t('status_offline')).toBe('Fråkopla modus');
|
||||
});
|
||||
|
||||
it('substitutes %d placeholders', async () => {
|
||||
mockLanguages.value = ['en'];
|
||||
const { initLocale, t } = await import('../src/i18n/i18n');
|
||||
initLocale();
|
||||
expect(t('status_shelters_loaded', 556)).toBe('556 shelters loaded');
|
||||
expect(t('shelter_capacity', 200)).toBe('200 places');
|
||||
});
|
||||
|
||||
it('falls back to English for unknown locale', async () => {
|
||||
mockLanguages.value = ['fr'];
|
||||
const { initLocale, t } = await import('../src/i18n/i18n');
|
||||
initLocale();
|
||||
expect(t('status_ready')).toBe('Ready');
|
||||
});
|
||||
|
||||
it('returns key for unknown string key', async () => {
|
||||
const { t } = await import('../src/i18n/i18n');
|
||||
expect(t('nonexistent_key')).toBe('nonexistent_key');
|
||||
});
|
||||
});
|
||||
70
pwa/tests/shelter-finder.test.ts
Normal file
70
pwa/tests/shelter-finder.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { findNearest } from '../src/location/shelter-finder';
|
||||
import type { Shelter } from '../src/types';
|
||||
|
||||
const shelters: Shelter[] = [
|
||||
{
|
||||
lokalId: 'a',
|
||||
romnr: 1,
|
||||
plasser: 100,
|
||||
adresse: 'Near',
|
||||
latitude: 59.91,
|
||||
longitude: 10.75,
|
||||
},
|
||||
{
|
||||
lokalId: 'b',
|
||||
romnr: 2,
|
||||
plasser: 200,
|
||||
adresse: 'Mid',
|
||||
latitude: 59.95,
|
||||
longitude: 10.75,
|
||||
},
|
||||
{
|
||||
lokalId: 'c',
|
||||
romnr: 3,
|
||||
plasser: 300,
|
||||
adresse: 'Far',
|
||||
latitude: 60.0,
|
||||
longitude: 10.75,
|
||||
},
|
||||
{
|
||||
lokalId: 'd',
|
||||
romnr: 4,
|
||||
plasser: 400,
|
||||
adresse: 'Very Far',
|
||||
latitude: 61.0,
|
||||
longitude: 10.75,
|
||||
},
|
||||
];
|
||||
|
||||
describe('findNearest', () => {
|
||||
it('returns shelters sorted by distance', () => {
|
||||
const result = findNearest(shelters, 59.9, 10.75);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].shelter.lokalId).toBe('a');
|
||||
expect(result[1].shelter.lokalId).toBe('b');
|
||||
expect(result[2].shelter.lokalId).toBe('c');
|
||||
});
|
||||
|
||||
it('returns requested count', () => {
|
||||
const result = findNearest(shelters, 59.9, 10.75, 2);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns all if fewer than count', () => {
|
||||
const result = findNearest(shelters.slice(0, 1), 59.9, 10.75, 5);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('calculates distance and bearing', () => {
|
||||
const result = findNearest(shelters, 59.9, 10.75, 1);
|
||||
expect(result[0].distanceMeters).toBeGreaterThan(0);
|
||||
expect(result[0].bearingDegrees).toBeGreaterThanOrEqual(0);
|
||||
expect(result[0].bearingDegrees).toBeLessThan(360);
|
||||
});
|
||||
|
||||
it('handles empty shelter list', () => {
|
||||
const result = findNearest([], 59.9, 10.75);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue