diff --git a/country-explorer/src/components/App.test.tsx b/country-explorer/src/components/App.test.tsx index ee57e11..9f40a98 100644 --- a/country-explorer/src/components/App.test.tsx +++ b/country-explorer/src/components/App.test.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getCountriesByRegion } from '../api/countries'; import { mockCountries } from '../test/fixtures'; +import type { Country } from '../types/country'; import App from './App'; vi.mock('../api/countries', async (importOriginal) => { @@ -21,6 +22,76 @@ function renderApp() { ); } +// Spans multiple subregions with population figures that don't follow the +// alphabetical order, so region filtering and each sort option can be told +// apart from one another. +const filterSortCountries: Country[] = [ + { + code: 'DEU', + name: 'Germany', + capital: 'Berlin', + region: 'Europe', + subregion: 'Western Europe', + population: 20000000, + area: 357114, + flagUrl: 'https://example.com/flags/de.svg', + currencies: ['Euro (EUR)'], + languages: ['German'], + }, + { + code: 'ITA', + name: 'Italy', + capital: 'Rome', + region: 'Europe', + subregion: 'Southern Europe', + population: 60000000, + area: 301340, + flagUrl: 'https://example.com/flags/it.svg', + currencies: ['Euro (EUR)'], + languages: ['Italian'], + }, + { + code: 'NOR', + name: 'Norway', + capital: 'Oslo', + region: 'Europe', + subregion: 'Northern Europe', + population: 5379475, + area: 323802, + flagUrl: 'https://example.com/flags/no.svg', + currencies: ['Norwegian krone (NOK)'], + languages: ['Norwegian'], + }, + { + code: 'SWE', + name: 'Sweden', + capital: 'Stockholm', + region: 'Europe', + subregion: 'Northern Europe', + population: 10353442, + area: 450295, + flagUrl: 'https://example.com/flags/se.svg', + currencies: ['Swedish krona (SEK)'], + languages: ['Swedish'], + }, +]; + +// Opens a Dropdown by its current accessible name (label + selected value). +function openDropdown(name: string) { + fireEvent.click(screen.getByRole('button', { name })); +} + +// The selected country is kept across a filter/sort change when it's still +// in the list, so order-dependent assertions rewind to the first country +// first rather than assuming what's currently selected. +function rewindToFirstCountry() { + let previous = screen.getByRole('button', { name: 'Previous country' }); + while (!previous.hasAttribute('disabled')) { + fireEvent.click(previous); + previous = screen.getByRole('button', { name: 'Previous country' }); + } +} + beforeEach(() => { window.sessionStorage.clear(); window.localStorage.clear(); @@ -63,7 +134,8 @@ describe('App navigation', () => { renderApp(); await screen.findByRole('heading', { name: 'Norway' }); - fireEvent.change(screen.getByLabelText('Country'), { target: { value: 'SWE' } }); + openDropdown('Country Norway'); + fireEvent.click(screen.getByRole('radio', { name: 'Sweden' })); expect(screen.getByRole('heading', { name: 'Sweden' })).toBeInTheDocument(); expect(screen.getByText('Stockholm')).toBeInTheDocument(); @@ -82,3 +154,89 @@ describe('App navigation', () => { expect(screen.getByRole('button', { name: 'Next country' })).toBeDisabled(); }); }); + +describe('App filtering and sorting', () => { + it('filters the countries down to the selected region', async () => { + mockedGetCountriesByRegion.mockResolvedValue(filterSortCountries); + renderApp(); + await screen.findByRole('heading', { name: 'Germany' }); + + openDropdown('Region All regions'); + fireEvent.click(screen.getByRole('radio', { name: 'Northern Europe' })); + + expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument(); + expect(screen.getByText('Country 1 of 2')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Next country' })); + + expect(screen.getByRole('heading', { name: 'Sweden' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next country' })).toBeDisabled(); + }); + + it('brings every country back into view once "All regions" is selected', async () => { + mockedGetCountriesByRegion.mockResolvedValue(filterSortCountries); + renderApp(); + await screen.findByRole('heading', { name: 'Germany' }); + + openDropdown('Region All regions'); + fireEvent.click(screen.getByRole('radio', { name: 'Northern Europe' })); + expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument(); + expect(screen.getByText('Country 1 of 2')).toBeInTheDocument(); + + openDropdown('Region Northern Europe'); + fireEvent.click(screen.getByRole('radio', { name: 'All regions' })); + + // the previously selected country stays selected since it's still + // available, but the full four-country list is back in view + expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument(); + expect(screen.getByText('Country 3 of 4')).toBeInTheDocument(); + + rewindToFirstCountry(); + expect(screen.getByRole('heading', { name: 'Germany' })).toBeInTheDocument(); + }); + + it('sorts the countries alphabetically by name', async () => { + mockedGetCountriesByRegion.mockResolvedValue(filterSortCountries); + renderApp(); + await screen.findByRole('heading', { name: 'Germany' }); + rewindToFirstCountry(); + + const expectedOrder = ['Germany', 'Italy', 'Norway', 'Sweden']; + expect(screen.getByRole('heading', { name: expectedOrder[0] })).toBeInTheDocument(); + + for (const name of expectedOrder.slice(1)) { + fireEvent.click(screen.getByRole('button', { name: 'Next country' })); + expect(screen.getByRole('heading', { name })).toBeInTheDocument(); + } + expect(screen.getByRole('button', { name: 'Next country' })).toBeDisabled(); + }); + + it('sorts the countries by population from high to low', async () => { + mockedGetCountriesByRegion.mockResolvedValue(filterSortCountries); + renderApp(); + await screen.findByRole('heading', { name: 'Germany' }); + + openDropdown('Sort by Name (A–Z)'); + fireEvent.click(screen.getByRole('radio', { name: 'Population (high to low)' })); + rewindToFirstCountry(); + + const expectedOrder = ['Italy', 'Germany', 'Sweden', 'Norway']; + expect(screen.getByRole('heading', { name: expectedOrder[0] })).toBeInTheDocument(); + + for (const name of expectedOrder.slice(1)) { + fireEvent.click(screen.getByRole('button', { name: 'Next country' })); + expect(screen.getByRole('heading', { name })).toBeInTheDocument(); + } + expect(screen.getByRole('button', { name: 'Next country' })).toBeDisabled(); + }); + + it('shows a message when the selected region has no matching countries', async () => { + window.sessionStorage.setItem('country-explorer:selectedRegion', 'Eastern Europe'); + mockedGetCountriesByRegion.mockResolvedValue(filterSortCountries); + renderApp(); + + await screen.findByText('No countries match the selected region.'); + + expect(screen.queryByRole('heading', { level: 3 })).not.toBeInTheDocument(); + }); +}); diff --git a/country-explorer/src/utils/sortCountries.test.ts b/country-explorer/src/utils/sortCountries.test.ts new file mode 100644 index 0000000..5544eaf --- /dev/null +++ b/country-explorer/src/utils/sortCountries.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import type { Country } from '../types/country'; +import { isSortOption, sortCountries } from './sortCountries'; + +function makeCountry(overrides: Partial): Country { + return { + code: 'XXX', + name: 'Country', + capital: '', + region: '', + subregion: '', + population: null, + area: null, + flagUrl: '', + currencies: [], + languages: [], + ...overrides, + }; +} + +const norway = makeCountry({ code: 'NOR', name: 'Norway', population: 5379475 }); +const sweden = makeCountry({ code: 'SWE', name: 'Sweden', population: 10353442 }); +const iceland = makeCountry({ code: 'ISL', name: 'Iceland', population: 372520 }); +const unknownPopulation = makeCountry({ code: 'ATL', name: 'Atlantis', population: null }); + +describe('sortCountries', () => { + it('sorts alphabetically by name for name-asc', () => { + const result = sortCountries([sweden, norway, iceland], 'name-asc'); + + expect(result.map((country) => country.name)).toEqual(['Iceland', 'Norway', 'Sweden']); + }); + + it('sorts by population from high to low for population-desc', () => { + const result = sortCountries([iceland, sweden, norway], 'population-desc'); + + expect(result.map((country) => country.name)).toEqual(['Sweden', 'Norway', 'Iceland']); + }); + + it('sorts countries with unknown population last', () => { + const result = sortCountries([unknownPopulation, sweden, iceland], 'population-desc'); + + expect(result.map((country) => country.name)).toEqual(['Sweden', 'Iceland', 'Atlantis']); + }); + + it('returns an empty array for both sort options', () => { + expect(sortCountries([], 'name-asc')).toEqual([]); + expect(sortCountries([], 'population-desc')).toEqual([]); + }); + + it('does not modify the original array or the countries in it', () => { + const original = [sweden, norway, iceland]; + const originalOrder = [...original]; + + const result = sortCountries(original, 'name-asc'); + + expect(original).toEqual(originalOrder); + expect(result).not.toBe(original); + }); + + it('recognizes valid and invalid sort option values', () => { + expect(isSortOption('name-asc')).toBe(true); + expect(isSortOption('population-desc')).toBe(true); + expect(isSortOption('area-desc')).toBe(false); + expect(isSortOption(null)).toBe(false); + }); +});