mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
cb7014b378
Costs gets a second view next to the ledger: the expenses as a table grouped by category, with name, date, total, persons and days editable in place and the per person and per day figures worked out beside them. A total with a payer or in a foreign currency stays locked and opens the expense instead, so balances and frozen rates are never changed behind the dialog. In the table view the side column shows a summary by category, day, payer and status. The switch sits in the bar and the choice is remembered; the ledger stays the default. On the Plan tab a place's reservation shows as one Reservation pill whose colour carries the status and which opens the booking, and the drop line of a drag that lost its source row now clears on the next mouse move. The booking detail formats its times in the user's clock, and a hotel without a check-out runs to the end of the last day in the timeline. The wiki pages of the trip are rewritten for the new layout, with new screenshots from the screenshot run (new Plan tab, bookings, transports, dialogs, costs table). The help center guides of the Plan tab are rewritten and translated into every locale, and every new string is translated.
278 lines
13 KiB
TypeScript
278 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
|
import { resetAllStores } from '../../../tests/helpers/store'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import WeatherWidget from './WeatherWidget'
|
|
|
|
vi.mock('../../api/client', async (importOriginal) => {
|
|
const original = await importOriginal() as any
|
|
return {
|
|
...original,
|
|
weatherApi: {
|
|
get: vi.fn(),
|
|
},
|
|
}
|
|
})
|
|
|
|
// Import after mock so we get the mocked version
|
|
import { weatherApi } from '../../api/client'
|
|
|
|
const buildWeather = (overrides = {}) => ({
|
|
temp: 20,
|
|
main: 'Clear',
|
|
description: 'clear sky',
|
|
type: 'forecast',
|
|
...overrides,
|
|
})
|
|
|
|
beforeEach(() => {
|
|
sessionStorage.clear()
|
|
vi.clearAllMocks()
|
|
resetAllStores()
|
|
})
|
|
|
|
describe('WeatherWidget', () => {
|
|
it('FE-COMP-WEATHERWIDGET-001: renders nothing when lat or lng is null', () => {
|
|
const { container } = render(
|
|
<WeatherWidget lat={null} lng={null} date="2025-06-01" />
|
|
)
|
|
expect(container.firstChild).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-002: shows loading indicator while fetching', () => {
|
|
vi.mocked(weatherApi.get).mockReturnValue(new Promise(() => {}))
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
expect(screen.getByText('…')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-003: shows error dash when fetch fails', async () => {
|
|
vi.mocked(weatherApi.get).mockRejectedValue(new Error('Network error'))
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('—')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-004: shows error dash when API returns error field', async () => {
|
|
vi.mocked(weatherApi.get).mockResolvedValue({ temp: 0, main: '', description: '', type: '', error: 'Not available' })
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('—')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-005: displays temperature in Celsius', async () => {
|
|
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 20 }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('20°C')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-006: converts temperature to Fahrenheit', async () => {
|
|
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 20 }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'fahrenheit' } })
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('68°F')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-007: shows "Ø" prefix for climate data', async () => {
|
|
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 15, main: 'Clouds', type: 'climate' }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Ø/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-008: compact mode renders inline without description', async () => {
|
|
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ description: 'clear sky' }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
const { container } = render(
|
|
<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" compact={true} />
|
|
)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('20°C')).toBeInTheDocument()
|
|
})
|
|
expect(screen.queryByText('clear sky')).not.toBeInTheDocument()
|
|
// Outer element should be a span
|
|
const tempSpan = screen.getByText('20°C')
|
|
expect(tempSpan.closest('span')).toBeInTheDocument()
|
|
expect(container.querySelector('div')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-009: non-compact mode shows description', async () => {
|
|
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ description: 'clear sky' }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" compact={false} />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('clear sky')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-010: uses fresh cached data from sessionStorage without refetching', async () => {
|
|
const cached = buildWeather({ temp: 20 })
|
|
sessionStorage.setItem('weather_48.86_2.35_2025-06-01_en', JSON.stringify({ data: cached, fetchedAt: Date.now() }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('20°C')).toBeInTheDocument()
|
|
})
|
|
expect(weatherApi.get).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-011: re-fetches in background for cached climate data', async () => {
|
|
const climateData = buildWeather({ temp: 15, main: 'Clouds', type: 'climate', description: 'cloudy' })
|
|
const forecastData = buildWeather({ temp: 22, main: 'Clear', type: 'forecast', description: 'clear sky' })
|
|
sessionStorage.setItem('weather_48.86_2.35_2025-06-01_en', JSON.stringify({ data: climateData, fetchedAt: Date.now() }))
|
|
vi.mocked(weatherApi.get).mockResolvedValue(forecastData)
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
|
|
// Initially shows climate data
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Ø/)).toBeInTheDocument()
|
|
})
|
|
|
|
// After background fetch resolves, shows forecast data
|
|
await waitFor(() => {
|
|
expect(screen.getByText('22°C')).toBeInTheDocument()
|
|
})
|
|
expect(screen.queryByText(/Ø/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
// #2167 — a standalone PWA keeps sessionStorage alive across days, so a
|
|
// forecast fetched before the trip was still shown unchanged on the day itself.
|
|
it('FE-COMP-WEATHERWIDGET-012: revalidates a forecast cache entry older than the TTL', async () => {
|
|
const staleData = buildWeather({ temp: 20 })
|
|
const freshData = buildWeather({ temp: 22 })
|
|
sessionStorage.setItem(
|
|
'weather_48.86_2.35_2025-06-01_en',
|
|
JSON.stringify({ data: staleData, fetchedAt: Date.now() - 2 * 60 * 60 * 1000 }),
|
|
)
|
|
vi.mocked(weatherApi.get).mockResolvedValue(freshData)
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
|
|
// Serve-then-revalidate: the stale value renders immediately …
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/20°C|22°C/)).toBeInTheDocument()
|
|
})
|
|
// … and the background refresh replaces it and the cache entry.
|
|
await waitFor(() => {
|
|
expect(screen.getByText('22°C')).toBeInTheDocument()
|
|
})
|
|
expect(weatherApi.get).toHaveBeenCalledTimes(1)
|
|
const stored = JSON.parse(sessionStorage.getItem('weather_48.86_2.35_2025-06-01_en')!)
|
|
expect(stored.data.temp).toBe(22)
|
|
expect(typeof stored.fetchedAt).toBe('number')
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-013: treats a pre-TTL raw cache entry as stale and refreshes it', async () => {
|
|
// Live PWA sessions still hold entries in the old bare-result format.
|
|
sessionStorage.setItem('weather_48.86_2.35_2025-06-01_en', JSON.stringify(buildWeather({ temp: 18 })))
|
|
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 25 }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('25°C')).toBeInTheDocument()
|
|
})
|
|
expect(weatherApi.get).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
// #2167 (mrwulf) — the widget must be able to say WHERE the forecast is for.
|
|
it('FE-COMP-WEATHERWIDGET-014: renders locationName as a tooltip title', async () => {
|
|
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 20 }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" locationName="Rome" />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('20°C')).toBeInTheDocument()
|
|
})
|
|
expect(screen.getByTitle('Rome')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-015: sends the user language and keys the cache by it', async () => {
|
|
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 20 }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius', language: 'de' } })
|
|
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('20°C')).toBeInTheDocument()
|
|
})
|
|
expect(weatherApi.get).toHaveBeenCalledWith(48.86, 2.35, '2025-06-01', 'de')
|
|
expect(sessionStorage.getItem('weather_48.86_2.35_2025-06-01_de')).not.toBeNull()
|
|
})
|
|
|
|
// The day-local anchor (#2167) moves whenever the day is edited, so a single
|
|
// failed lookup must not keep the badge on the dash for the widget's lifetime.
|
|
it('FE-COMP-WEATHERWIDGET-016: clears the error dash once a new anchor resolves', async () => {
|
|
vi.mocked(weatherApi.get).mockRejectedValueOnce(new Error('Network error'))
|
|
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 21 }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
|
|
const { rerender } = render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('—')).toBeInTheDocument()
|
|
})
|
|
|
|
rerender(<WeatherWidget lat={41.9} lng={12.5} date="2025-06-01" />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('21°C')).toBeInTheDocument()
|
|
})
|
|
expect(screen.queryByText('—')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-017: clears the error dash when the new anchor comes from cache', async () => {
|
|
vi.mocked(weatherApi.get).mockRejectedValue(new Error('Network error'))
|
|
sessionStorage.setItem(
|
|
'weather_41.9_12.5_2025-06-01_en',
|
|
JSON.stringify({ data: buildWeather({ temp: 24 }), fetchedAt: Date.now() }),
|
|
)
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
|
|
const { rerender } = render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('—')).toBeInTheDocument()
|
|
})
|
|
|
|
rerender(<WeatherWidget lat={41.9} lng={12.5} date="2025-06-01" />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('24°C')).toBeInTheDocument()
|
|
})
|
|
expect(weatherApi.get).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-018: the stacked badge shows the icon over the temperature, without a tooltip when it has no place', async () => {
|
|
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 21 }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
const { container } = render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" stacked />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('21°')).toBeInTheDocument()
|
|
})
|
|
expect(container.querySelector('svg')).not.toBeNull()
|
|
fireEvent.mouseEnter(screen.getByText('21°').parentElement!)
|
|
await new Promise(r => setTimeout(r, 300))
|
|
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-WEATHERWIDGET-019: without its icon the stacked badge names the sky and the place in its tooltip', async () => {
|
|
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 18, type: 'climate' }))
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
|
const { container } = render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" stacked hideIcon locationName="Rome" />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Ø18°')).toBeInTheDocument()
|
|
})
|
|
expect(container.querySelector('svg')).toBeNull()
|
|
fireEvent.mouseEnter(screen.getByText('Ø18°').parentElement!)
|
|
expect(await screen.findByRole('tooltip')).toHaveTextContent('clear sky, Rome')
|
|
})
|
|
})
|