mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
36ac81322a
wiki/Languages.md has the account's language first, then the browser's. The browser step only ran on the login page, so opening a trip with a live session in a new browser (or after the site data was cleared) showed English to anyone whose account had no language of its own. The app now starts in the browser's language when nothing was chosen in this browser, and keeps it when the account has none. Nothing is written to the account; a language set in the account or picked in the app still wins. The detection moved to its own module so the settings store can use it at startup. Refs #2604
211 lines
9.7 KiB
TypeScript
211 lines
9.7 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { DEFAULT_SETTINGS, forgetServerLanguage, useSettingsStore } from './settingsStore'
|
|
import { settingsApi } from '../api/client'
|
|
import { clearTileCache } from '../sync/tilePrefetcher'
|
|
|
|
vi.mock('../api/client', () => ({
|
|
settingsApi: {
|
|
get: vi.fn(),
|
|
set: vi.fn().mockResolvedValue(undefined),
|
|
setBulk: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
}))
|
|
|
|
vi.mock('../sync/tilePrefetcher', () => ({
|
|
clearTileCache: vi.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
// A fresh instance sends no value for a setting an admin hasn't defaulted, so DEFAULT_SETTINGS
|
|
// is what a brand-new user actually sees. These guard against the two regressions in the
|
|
// original bug: unit defaults that mix measurement systems (°F alongside kilometres), and a
|
|
// store default that silently disagrees with DisplaySettingsTab's fallback.
|
|
describe('settings defaults', () => {
|
|
it('SETTINGS-DEFAULTS-001: the shipped unit defaults belong to one consistent system', () => {
|
|
expect(DEFAULT_SETTINGS.temperature_unit).toBe('celsius')
|
|
expect(DEFAULT_SETTINGS.distance_unit).toBe('metric')
|
|
expect(DEFAULT_SETTINGS.time_format).toBe('24h')
|
|
})
|
|
|
|
it('SETTINGS-DEFAULTS-002: the store initialises from DEFAULT_SETTINGS, the same constant DisplaySettingsTab falls back to, so the two cannot drift apart', () => {
|
|
const settings = useSettingsStore.getState().settings
|
|
expect(settings.temperature_unit).toBe(DEFAULT_SETTINGS.temperature_unit)
|
|
expect(settings.distance_unit).toBe(DEFAULT_SETTINGS.distance_unit)
|
|
expect(settings.time_format).toBe(DEFAULT_SETTINGS.time_format)
|
|
})
|
|
|
|
it('SETTINGS-DEFAULTS-003: no CARTO key is shipped, so the field starts empty instead of undefined', () => {
|
|
expect(DEFAULT_SETTINGS.carto_api_key).toBe('')
|
|
})
|
|
})
|
|
|
|
// The CARTO key lives in its own setting and is appended at render time (#2054). Two things
|
|
// have to hold on the way out to the server: the key never gets frozen into the saved
|
|
// template, and a key change invalidates the tile cache, which is keyed by the full URL.
|
|
describe('settings tile template hygiene', () => {
|
|
const initialState = useSettingsStore.getState()
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
useSettingsStore.setState(initialState, true)
|
|
})
|
|
|
|
it('SETTINGS-TILE-001: a key pasted into the template is stripped before the template is stored', async () => {
|
|
await useSettingsStore.getState().updateSetting(
|
|
'map_tile_url',
|
|
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png?key=pasted-secret',
|
|
)
|
|
const stored = useSettingsStore.getState().settings.map_tile_url
|
|
expect(stored).toBe('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png')
|
|
expect(stored).not.toContain('pasted-secret')
|
|
expect(settingsApi.set).toHaveBeenCalledWith('map_tile_url', stored)
|
|
})
|
|
|
|
it('SETTINGS-TILE-002: a bulk save strips the key and normalizes the retired OSM shard host', async () => {
|
|
await useSettingsStore.getState().updateSettings({
|
|
map_tile_url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png?key=pasted-secret',
|
|
})
|
|
const stored = useSettingsStore.getState().settings.map_tile_url
|
|
expect(stored).toBe('https://tile.openstreetmap.org/{z}/{x}/{y}.png')
|
|
expect(settingsApi.setBulk).toHaveBeenCalledWith({ map_tile_url: stored })
|
|
})
|
|
|
|
it('SETTINGS-TILE-003: changing the CARTO key drops the tile cache', async () => {
|
|
await useSettingsStore.getState().updateSetting('carto_api_key', 'fresh-key')
|
|
expect(clearTileCache).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('SETTINGS-TILE-004: re-saving the same key leaves the cache alone', async () => {
|
|
await useSettingsStore.getState().updateSetting('carto_api_key', 'fresh-key')
|
|
vi.mocked(clearTileCache).mockClear()
|
|
await useSettingsStore.getState().updateSetting('carto_api_key', 'fresh-key')
|
|
expect(clearTileCache).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('SETTINGS-TILE-005: a bulk save clears the cache only when the key actually moves', async () => {
|
|
await useSettingsStore.getState().updateSettings({ carto_api_key: 'fresh-key' })
|
|
expect(clearTileCache).toHaveBeenCalledTimes(1)
|
|
|
|
vi.mocked(clearTileCache).mockClear()
|
|
await useSettingsStore.getState().updateSettings({ map_tile_url: '' })
|
|
expect(clearTileCache).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// The account's language lives on the server; the store boots from localStorage. Without a
|
|
// mirror of the server value, a cold start with no network (PWA in airplane mode) booted in
|
|
// English even though every online session ran in the user's language — the same stranding
|
|
// #1618 fixed for currency and units. 'app_language' stays reserved for an explicit in-app
|
|
// choice (the login page's detection chain keys off it), so the mirror gets its own key.
|
|
describe('offline language fallback', () => {
|
|
beforeEach(() => {
|
|
localStorage.removeItem('app_language')
|
|
localStorage.removeItem('app_language_server')
|
|
})
|
|
|
|
it('SETTINGS-LANG-001: loadSettings mirrors the account language for the next launch', async () => {
|
|
vi.mocked(settingsApi.get).mockResolvedValue({ settings: { language: 'fr' } } as never)
|
|
await useSettingsStore.getState().loadSettings()
|
|
expect(localStorage.getItem('app_language_server')).toBe('fr')
|
|
})
|
|
|
|
it('SETTINGS-LANG-002: the mirror never claims to be an explicit choice', async () => {
|
|
vi.mocked(settingsApi.get).mockResolvedValue({ settings: { language: 'fr' } } as never)
|
|
await useSettingsStore.getState().loadSettings()
|
|
expect(localStorage.getItem('app_language')).toBeNull()
|
|
})
|
|
|
|
it('SETTINGS-LANG-003: a cold start with no explicit choice boots in the mirrored language', async () => {
|
|
localStorage.setItem('app_language_server', 'fr')
|
|
vi.resetModules()
|
|
const fresh = await import('./settingsStore')
|
|
expect(fresh.DEFAULT_SETTINGS.language).toBe('fr')
|
|
})
|
|
|
|
it('SETTINGS-LANG-004: an explicit in-app choice outranks the mirror', async () => {
|
|
localStorage.setItem('app_language', 'de')
|
|
localStorage.setItem('app_language_server', 'fr')
|
|
vi.resetModules()
|
|
const fresh = await import('./settingsStore')
|
|
expect(fresh.DEFAULT_SETTINGS.language).toBe('de')
|
|
})
|
|
|
|
// The account of the user who just signed in decides, and an account without a
|
|
// language of its own sends no language key at all. Leaving the previous
|
|
// account's mirror standing is what stranded the next user on a shared browser.
|
|
it('SETTINGS-LANG-005: an account with no language of its own clears the mirror', async () => {
|
|
localStorage.setItem('app_language_server', 'ja')
|
|
vi.mocked(settingsApi.get).mockResolvedValue({ settings: { default_currency: 'EUR' } } as never)
|
|
|
|
await useSettingsStore.getState().loadSettings()
|
|
|
|
expect(localStorage.getItem('app_language_server')).toBeNull()
|
|
})
|
|
|
|
it('SETTINGS-LANG-006: a language TREK does not ship never reaches the mirror', async () => {
|
|
localStorage.setItem('app_language_server', 'ja')
|
|
vi.mocked(settingsApi.get).mockResolvedValue({ settings: { language: 'kl' } } as never)
|
|
|
|
await useSettingsStore.getState().loadSettings()
|
|
|
|
expect(localStorage.getItem('app_language_server')).toBeNull()
|
|
})
|
|
|
|
it('SETTINGS-LANG-007: forgetServerLanguage drops the mirror and leaves the explicit choice alone', () => {
|
|
localStorage.setItem('app_language', 'de')
|
|
localStorage.setItem('app_language_server', 'ja')
|
|
|
|
forgetServerLanguage()
|
|
|
|
expect(localStorage.getItem('app_language_server')).toBeNull()
|
|
expect(localStorage.getItem('app_language')).toBe('de')
|
|
})
|
|
})
|
|
|
|
// wiki/Languages.md: the account's language, then the browser's, then English
|
|
// (the server default is the login page's, as before). The browser step only ran there,
|
|
// so opening a trip with a live session in a fresh browser started in English (#2604).
|
|
describe('language detection with a session', () => {
|
|
const languages = Object.getOwnPropertyDescriptor(window.navigator, 'languages')
|
|
const speak = (...langs: string[]) =>
|
|
Object.defineProperty(window.navigator, 'languages', { value: langs, configurable: true })
|
|
|
|
beforeEach(() => {
|
|
localStorage.removeItem('app_language')
|
|
localStorage.removeItem('app_language_server')
|
|
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, language: 'en' } })
|
|
})
|
|
afterEach(() => {
|
|
if (languages) Object.defineProperty(window.navigator, 'languages', languages)
|
|
else delete (window.navigator as { languages?: unknown }).languages
|
|
})
|
|
|
|
it('SETTINGS-LANG-008: a cold start with nothing stored boots in the browser language', async () => {
|
|
speak('ko-KR', 'en')
|
|
vi.resetModules()
|
|
const fresh = await import('./settingsStore')
|
|
expect(fresh.DEFAULT_SETTINGS.language).toBe('ko')
|
|
})
|
|
|
|
it('SETTINGS-LANG-009: an account without a language takes the browser language, nothing is saved', async () => {
|
|
speak('ko-KR')
|
|
vi.mocked(settingsApi.get).mockResolvedValue({ settings: {} } as never)
|
|
await useSettingsStore.getState().loadSettings()
|
|
expect(useSettingsStore.getState().settings.language).toBe('ko')
|
|
expect(localStorage.getItem('app_language')).toBeNull()
|
|
expect(settingsApi.set).not.toHaveBeenCalledWith('language', expect.anything())
|
|
})
|
|
|
|
it('SETTINGS-LANG-011: the account language and an explicit choice both outrank the browser', async () => {
|
|
speak('ko-KR')
|
|
vi.mocked(settingsApi.get).mockResolvedValue({ settings: { language: 'fr' } } as never)
|
|
await useSettingsStore.getState().loadSettings()
|
|
expect(useSettingsStore.getState().settings.language).toBe('fr')
|
|
|
|
localStorage.setItem('app_language', 'de')
|
|
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, language: 'de' } })
|
|
vi.mocked(settingsApi.get).mockResolvedValue({ settings: {} } as never)
|
|
await useSettingsStore.getState().loadSettings()
|
|
expect(useSettingsStore.getState().settings.language).toBe('de')
|
|
})
|
|
})
|