diff --git a/README.md b/README.md
index cf8715c..43cfba3 100644
--- a/README.md
+++ b/README.md
@@ -440,10 +440,8 @@ Implementert:
- Filter som viser bare favorittland, lagret i sessionStorage.
- Responsiv layout for desktop, nettbrett og mobil i stående og liggende
format.
-
-Gjenstår i senere issues:
-
-- Automatiske tester, se [Testing](#testing).
+- Automatiske komponent-, hook- og integrasjonstester med Vitest, se
+ [Testing](#testing).
## Arbeidsflyt
@@ -453,7 +451,8 @@ pull requests med kodegjennomgang fra et annet gruppemedlem.
## Testing
-Automatiske komponent- og hook-tester kjøres med Vitest.
+Automatiske komponent-, hook- og integrasjonstester kjøres med Vitest, se
+[Automatisk testdekning](#automatisk-testdekning) for hva som er dekket.
Kommandoen `npm run lint` er statisk kodekontroll, ikke en funksjonstest.
### Testoppsett
@@ -672,44 +671,90 @@ Testet i nettleserens enhetsemulering:
- Lange landnavn, mange språk, store tall og manglende flagg ble testet med
midlertidige testdata og brøt ikke layouten.
- Lastetilstand, feilmelding og tomt favorittutvalg passer inn i layouten.
-- Ikke testet på ekte mobil eller i Safari ennå.
-
-Automatiske tester som gjenstår, planlagt i en egen issue:
-
-- `fetchCountries`: vellykket henting, HTTP-feil, nettverksfeil, ugyldig
- JSON, uventet format, tom respons, manglende felter og avbrutt
- forespørsel, med mocket `fetch`.
-- `App`: lastetilstand, feilmelding med riktig tekst, «Prøv igjen», tomt
- resultat, og at eksisterende data vises når en ny henting feiler.
-- `CountryCard`: fullstendige data, manglende felter og flagg som ikke
- kan lastes.
-- `CountryNavigation`: forrige/neste gir riktig land, knappene er
- deaktivert ved første og siste land, posisjonen «Land X av Y» og liste
- med ett land.
-- `CountryMenu`: klikk på et land velger det, valgt land har
- `aria-current`, favoritter er markert, og tomt utvalg gir melding.
- «Landliste»-knappen bytter `aria-expanded`, og valg av land lukker
- listen og flytter fokus til knappen.
-- `App`: kort og navigasjon viser samme land, og ugyldig eller manglende
- valgt kode gir første land.
-- `sortCountries`: navn med norske bokstaver, synkende befolkning,
- manglende befolkning sist, likt antall etter navn og uendret original.
-- `loadSortOrder`/`saveSortOrder`: gyldig og ugyldig lagret verdi, og
- lagring som kaster feil.
-- `App` med sortering: valgt land beholdes ved bytte av sortering,
- navigasjonen følger sortert liste, og valget gjenopprettes ved ny
- montering.
-- `loadFavorites`/`saveFavorites`: gyldig liste, ugyldig JSON, feil
- format, duplikater og lagring som kaster feil.
-- `useFavorites`/`FavoriteButton`: legge til og fjerne favoritt,
- `aria-pressed`, riktig status ved blaing og gjenoppretting ved ny
- montering.
-- `loadShowOnlyFavorites`/`saveShowOnlyFavorites`: `"true"`, manglende
- eller ugyldig verdi, og lagring som kaster feil.
-- `App` med favorittfilter: utvalget inneholder bare favoritter i valgt
- sortering, valgt land beholdes eller byttes til første land, tomt
- utvalg viser melding og «Vis alle land», og fjerning av siste favoritt
- krasjer ikke.
+
+Testet på ekte enheter i Safari, med utviklingsserveren eksponert på
+nettverket (`npm run dev -- --host`):
+
+- **iPhone 13 Pro** (mobil, stående og liggende)
+- **iPad 11** (nettbrett, stående og liggende)
+
+Ingen horisontal scrolling eller layoutfeil på noen av enhetene.
+
+### Automatisk testdekning
+
+Se [Testoppsett](#testoppsett) for hvordan testene kjøres. Testene ligger
+ved siden av filen de tester (for eksempel `CountryCard.test.tsx` ved
+siden av `CountryCard.tsx`), og bruker mockede API-svar og en isolert
+`QueryClient` der det er aktuelt, se [Testoppsett](#testoppsett).
+
+- **`src/api/countries.test.ts`:** Et rått svar med fem land gir fire
+ gyldige `Country`-objekter. Et land uten `alpha3Code` hoppes over, et
+ land med bare `png`-flagg får `flagUrl` satt til `png`-URL-en, og
+ tekstverdier for `population`/`area` og en `languages`-verdi som ikke
+ er en array, blir til `undefined`/tom liste. Tomt svar (`[]`) gir tom
+ liste. HTTP 500, et objekt i stedet for en array, og en `fetch` som
+ kaster ved `json()`, gir hver sin feilmelding fra
+ [Feilhåndtering](#feilhåndtering). En avbrutt forespørsel (signal
+ allerede `aborted`) sender den opprinnelige feilen videre uendret.
+- **`src/App.test.tsx`:** En `fetch` som aldri løser seg, viser
+ «Laster land …». To mislykkede kall (første forsøk og det automatiske
+ nye forsøket, se `retry: 1` i [Hentestrategien](#hentestrategi-med-tanstack-query))
+ gir feilmeldingen med «HTTP 500», og et tredje kall via «Prøv igjen»
+ lykkes og viser «Land 1 av 2». Et tomt svar gir «Fant ingen land.».
+ Med Norway og Sweden i utvalget: å markere Norway som favoritt og slå
+ på favorittfilteret gir «Land 1 av 1» og bare Norway i visningen. Uten
+ favoritter gir filteret meldingen «Du har ingen favorittland å vise.»,
+ og «Vis alle land» gjenoppretter begge landene. Fjernes Norway som
+ favoritt mens filteret er på, vises samme melding.
+- **`src/components/CountryCard.test.tsx`:** Et fullstendig land viser
+ «5 400 000», «385 207 km²» og «Norwegian og Sami». Et land uten
+ hovedstad, region, befolkning, areal, språk eller flagg viser «Ikke
+ oppgitt» for hvert felt og «Flagg ikke tilgjengelig» i stedet for
+ bilde. Et flagg som feiler ved lasting (`onError`), bytter til samme
+ reservetekst.
+- **`src/components/CountryMenu.test.tsx`:** Klikk på «Sweden» kaller
+ `onSelect('SWE')`, og valgt land får `aria-current="true"`. Er Sweden
+ favoritt, viser knappen stjerne og skjult tekst «(favoritt)». Tomt
+ utvalg viser «Ingen land i utvalget.» uten landliste. Åpnes
+ «Landliste»-knappen og et land velges, settes `aria-expanded` til
+ `false`, og fokus flyttes til knappen.
+- **`src/components/CountryNavigation.test.tsx`:** Med Norway, Sweden og
+ Denmark, og Sweden valgt, viser komponenten «Land 2 av 3». «Neste»
+ kaller `onSelect('DNK')`, og «Forrige» kaller `onSelect('NOR')`.
+ «Forrige» er deaktivert ved Norway, «Neste» ved Denmark. En valgt kode
+ som ikke finnes i utvalget, gir ingen rendret komponent.
+- **`src/components/FavoriteButton.test.tsx`:** Klikk kaller `onToggle`
+ én gang, og `aria-pressed` er `"true"`/`"false"` i tråd med props. En
+ snapshot-test fanger markeringen (☆/★) i begge tilstander.
+- **`src/hooks/useFavorites.test.ts`:** `toggleFavorite('NOR')` gjør
+ `isFavorite('NOR')` sann og lagrer `["NOR"]` i localStorage. Er
+ `["BRA","BGD"]` lagret fra før, gjenoppretter en ny montering av hooken
+ favorittstatusen for begge.
+- **`src/utils/favoritesStorage.test.ts`:** `["NOR","SWE","NOR"]` gir
+ `["NOR","SWE"]` uten duplikat, og
+ `["NOR", 7, null, "", {"a":1}, "SWE"]` gir samme resultat, siden
+ verdier som ikke er tekst eller er tomme, hoppes over. Ugyldig JSON og
+ en lagret verdi som ikke er en array, gir tom liste. Kaster
+ `localStorage` ved lesing eller skriving, brukes tom liste uten at
+ appen krasjer.
+- **`src/utils/sortCountries.test.ts`:** Navn gir «Albania, Brasil,
+ Ukjentland, Østland, Ålesundia» (norsk sortering: U før Æ, Ø og Å).
+ Befolkning gir «Brasil, Ålesundia, Østland, Albania, Ukjentland», med
+ manglende befolkning sist. Ved lik befolkning brukes navn som
+ tiebreak, og en frosset originalliste endres ikke.
+- **`src/utils/sortOrderStorage.test.ts`:** Lagret `"population"` leses
+ tilbake uendret, mens den ugyldige verdien `"hacket"` og en `getItem`
+ som kaster feil, begge gir standardvalget `"name"`.
+
+Gjenstår, ikke kritisk for innleveringen:
+
+- `useShowOnlyFavorites`/`favoritesFilterStorage`: egne tester av
+ lagringsfunksjonene, i tillegg til dekningen gjennom `App.test.tsx`.
+- `App` med sortering: at valgt land beholdes ved bytte av sortering, og
+ at navigasjonen følger den sorterte listen.
+- `App`: at eksisterende data vises videre når en ny henting feiler i
+ bakgrunnen (beskrevet i [Feilhåndtering](#feilhåndtering)).
+- `CountryNavigation` med bare ett land i utvalget.
## KI-bruk
diff --git a/src/App.test.tsx b/src/App.test.tsx
new file mode 100644
index 0000000..2ae0041
--- /dev/null
+++ b/src/App.test.tsx
@@ -0,0 +1,134 @@
+import { QueryClientProvider } from '@tanstack/react-query';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+import { createTestQueryClient } from './test/queryClient';
+import App from './App';
+
+const RAW_COUNTRIES = [
+ { alpha3Code: 'NOR', name: 'Norway', population: 5_400_000 },
+ { alpha3Code: 'SWE', name: 'Sweden', population: 10_000_000 },
+];
+
+function stubFetchResolved(json: unknown, ok = true, status = 200) {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok, status, json: async () => json }),
+ );
+}
+
+function renderApp() {
+ const queryClient = createTestQueryClient();
+ return render(
+
+
+ ,
+ );
+}
+
+describe('App', () => {
+ it('viser lastetilstand mens data hentes', () => {
+ // Løser seg aldri i denne testen, så komponenten blir stående i pending.
+ vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
+
+ renderApp();
+
+ expect(screen.getByRole('status')).toHaveTextContent('Laster land');
+ });
+
+ it('viser feilmelding ved HTTP-feil og henter på nytt ved «Prøv igjen»', async () => {
+ // useCountries har retry: 1, så de to første kallene (første forsøk og
+ // det automatiske nye forsøket) må feile før feilsiden vises.
+ let callCount = 0;
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockImplementation(async () => {
+ callCount += 1;
+ if (callCount <= 2) {
+ return { ok: false, status: 500, json: async () => null };
+ }
+ return { ok: true, status: 200, json: async () => RAW_COUNTRIES };
+ }),
+ );
+
+ renderApp();
+
+ const alert = await screen.findByRole('alert');
+ expect(alert).toHaveTextContent('HTTP 500');
+
+ await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' }));
+
+ expect(await screen.findByText('Land 1 av 2')).toBeInTheDocument();
+ });
+
+ it('viser melding når API-et ikke returnerer land', async () => {
+ stubFetchResolved([]);
+
+ renderApp();
+
+ expect(await screen.findByText('Fant ingen land.')).toBeInTheDocument();
+ });
+
+ it('viser melding om tomt favorittutvalg og lar brukeren vise alle land igjen', async () => {
+ stubFetchResolved(RAW_COUNTRIES);
+
+ renderApp();
+ await screen.findByText('Land 1 av 2');
+
+ await userEvent.click(
+ screen.getByRole('checkbox', { name: 'Vis bare favoritter' }),
+ );
+
+ expect(
+ screen.getByText('Du har ingen favorittland å vise.'),
+ ).toBeInTheDocument();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Vis alle land' }),
+ );
+
+ expect(await screen.findByText('Land 1 av 2')).toBeInTheDocument();
+ });
+
+ it('viser bare favoritter når filteret er på', async () => {
+ stubFetchResolved(RAW_COUNTRIES);
+
+ renderApp();
+ // Norway vises først, siden standardsorteringen er alfabetisk.
+ await screen.findByText('Land 1 av 2');
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Favoritt: Norway' }),
+ );
+ await userEvent.click(
+ screen.getByRole('checkbox', { name: 'Vis bare favoritter' }),
+ );
+
+ expect(screen.getByText('Land 1 av 1')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument();
+ });
+
+ it('viser tomt favorittutvalg når siste favoritt fjernes mens filteret er på', async () => {
+ stubFetchResolved(RAW_COUNTRIES);
+
+ renderApp();
+ await screen.findByText('Land 1 av 2');
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Favoritt: Norway' }),
+ );
+ await userEvent.click(
+ screen.getByRole('checkbox', { name: 'Vis bare favoritter' }),
+ );
+ expect(screen.getByText('Land 1 av 1')).toBeInTheDocument();
+
+ // Fjerner den eneste favoritten mens filteret fortsatt er på.
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Favoritt: Norway' }),
+ );
+
+ expect(
+ screen.getByText('Du har ingen favorittland å vise.'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/api/countries.test.ts b/src/api/countries.test.ts
new file mode 100644
index 0000000..bbd34a6
--- /dev/null
+++ b/src/api/countries.test.ts
@@ -0,0 +1,151 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fetchCountries } from './countries';
+
+function mockFetchOnce(response: {
+ ok: boolean;
+ status?: number;
+ json: () => Promise;
+}) {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ status: 200, ...response }),
+ );
+}
+
+describe('fetchCountries', () => {
+ it('mapper og validerer felter fra det rå API-svaret', async () => {
+ mockFetchOnce({
+ ok: true,
+ json: async () => [
+ {
+ alpha3Code: 'NOR',
+ name: 'Norway',
+ flags: { svg: 'nor.svg', png: 'nor.png' },
+ capital: 'Oslo',
+ region: 'Europe',
+ population: 5_400_000,
+ area: 385_207,
+ languages: [{ name: 'Norwegian' }],
+ },
+ // Bare kode og navn er nødvendig, resten blir undefined/tom liste.
+ { alpha3Code: 'SWE', name: 'Sweden' },
+ // Mangler kode: hoppes over.
+ { name: 'Uten kode' },
+ // Feil type på population/area, og languages er ikke en array.
+ {
+ alpha3Code: 'DNK',
+ name: 'Denmark',
+ population: 'mange',
+ area: 'stort',
+ languages: 'ikke en liste',
+ },
+ // Bare png-flagg tilgjengelig.
+ { alpha3Code: 'ISL', name: 'Iceland', flags: { png: 'isl.png' } },
+ ],
+ });
+
+ const countries = await fetchCountries();
+
+ expect(countries).toEqual([
+ {
+ code: 'NOR',
+ name: 'Norway',
+ flagUrl: 'nor.svg',
+ capital: 'Oslo',
+ region: 'Europe',
+ population: 5_400_000,
+ area: 385_207,
+ languages: ['Norwegian'],
+ },
+ {
+ code: 'SWE',
+ name: 'Sweden',
+ flagUrl: undefined,
+ capital: undefined,
+ region: undefined,
+ population: undefined,
+ area: undefined,
+ languages: [],
+ },
+ {
+ code: 'DNK',
+ name: 'Denmark',
+ flagUrl: undefined,
+ capital: undefined,
+ region: undefined,
+ population: undefined,
+ area: undefined,
+ languages: [],
+ },
+ {
+ code: 'ISL',
+ name: 'Iceland',
+ flagUrl: 'isl.png',
+ capital: undefined,
+ region: undefined,
+ population: undefined,
+ area: undefined,
+ languages: [],
+ },
+ ]);
+ });
+
+ it('gir tom liste for et tomt svar', async () => {
+ mockFetchOnce({ ok: true, json: async () => [] });
+
+ expect(await fetchCountries()).toEqual([]);
+ });
+
+ it('gir feil ved HTTP-feil', async () => {
+ mockFetchOnce({ ok: false, status: 500, json: async () => null });
+
+ await expect(fetchCountries()).rejects.toThrow(
+ 'API-et svarte med en feil (HTTP 500).',
+ );
+ });
+
+ it('gir feil når svaret ikke er en array', async () => {
+ mockFetchOnce({ ok: true, json: async () => ({ not: 'an array' }) });
+
+ await expect(fetchCountries()).rejects.toThrow(
+ 'Svaret fra API-et hadde et uventet format.',
+ );
+ });
+
+ it('gir feil når svaret ikke er gyldig JSON', async () => {
+ mockFetchOnce({
+ ok: true,
+ json: async () => {
+ throw new Error('unexpected token');
+ },
+ });
+
+ await expect(fetchCountries()).rejects.toThrow(
+ 'Svaret fra API-et var ikke gyldig JSON.',
+ );
+ });
+
+ it('gir feil ved nettverksfeil', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockRejectedValue(new TypeError('Failed to fetch')),
+ );
+
+ await expect(fetchCountries()).rejects.toThrow(
+ 'Fikk ikke kontakt med API-et. Sjekk nettforbindelsen.',
+ );
+ });
+
+ it('sender avbrutt forespørsel videre uendret', async () => {
+ const abortError = new DOMException(
+ 'The operation was aborted.',
+ 'AbortError',
+ );
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError));
+
+ const controller = new AbortController();
+ controller.abort();
+
+ await expect(fetchCountries(controller.signal)).rejects.toBe(abortError);
+ });
+});
diff --git a/src/components/CountryCard.test.tsx b/src/components/CountryCard.test.tsx
new file mode 100644
index 0000000..ade5e7d
--- /dev/null
+++ b/src/components/CountryCard.test.tsx
@@ -0,0 +1,69 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { createMockCountry } from '../test/mockCountry';
+import CountryCard from './CountryCard';
+
+describe('CountryCard', () => {
+ it('viser alle fakta når landet har fullstendige data', () => {
+ const country = createMockCountry({
+ languages: ['Norwegian', 'Sami'],
+ });
+ const { container } = render();
+
+ expect(screen.getByRole('article', { name: 'Norway' })).toBeInTheDocument();
+ expect(
+ screen.getByRole('img', { name: 'Flagget til Norway' }),
+ ).toHaveAttribute('src', country.flagUrl);
+
+ const facts = container.querySelectorAll('dd');
+ expect(facts[0]).toHaveTextContent('Oslo');
+ expect(facts[1]).toHaveTextContent('Europe');
+ expect(facts[2]).toHaveTextContent('5 400 000');
+ expect(facts[3]).toHaveTextContent('385 207 km²');
+ expect(facts[4]).toHaveTextContent('Norwegian og Sami');
+ });
+
+ it('viser «Ikke oppgitt» og reservetekst for flagg når data mangler', () => {
+ const country = createMockCountry({
+ flagUrl: undefined,
+ capital: undefined,
+ region: undefined,
+ population: undefined,
+ area: undefined,
+ languages: [],
+ });
+ const { container } = render();
+
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ expect(screen.getByText('Flagg ikke tilgjengelig')).toBeInTheDocument();
+
+ const facts = container.querySelectorAll('dd');
+ facts.forEach((fact) => {
+ expect(fact).toHaveTextContent('Ikke oppgitt');
+ });
+ });
+
+ it('viser reservetekst når flaggbildet ikke kan lastes', () => {
+ const country = createMockCountry();
+ render();
+
+ const flag = screen.getByRole('img', { name: 'Flagget til Norway' });
+ fireEvent.error(flag);
+
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ expect(screen.getByText('Flagg ikke tilgjengelig')).toBeInTheDocument();
+ });
+
+ it('rendrer children ved siden av landnavnet', () => {
+ const country = createMockCountry();
+ render(
+
+
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'Favoritt' }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/CountryMenu.test.tsx b/src/components/CountryMenu.test.tsx
new file mode 100644
index 0000000..a37e9b1
--- /dev/null
+++ b/src/components/CountryMenu.test.tsx
@@ -0,0 +1,85 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+import { createMockCountry } from '../test/mockCountry';
+import CountryMenu from './CountryMenu';
+
+const countries = [
+ createMockCountry({ code: 'NOR', name: 'Norway' }),
+ createMockCountry({ code: 'SWE', name: 'Sweden' }),
+];
+
+describe('CountryMenu', () => {
+ it('velger et land ved klikk og markerer det med aria-current', async () => {
+ const onSelect = vi.fn();
+ render(
+ false}
+ />,
+ );
+
+ const selected = screen.getByRole('button', { name: 'Norway' });
+ const other = screen.getByRole('button', { name: 'Sweden' });
+ expect(selected).toHaveAttribute('aria-current', 'true');
+ expect(other).not.toHaveAttribute('aria-current');
+
+ await userEvent.click(other);
+
+ expect(onSelect).toHaveBeenCalledWith('SWE');
+ });
+
+ it('markerer favorittland med stjerne og skjult tekst', () => {
+ render(
+ code === 'SWE'}
+ />,
+ );
+
+ const favoriteItem = screen.getByRole('button', { name: /Sweden/ });
+ expect(favoriteItem).toHaveTextContent('(favoritt)');
+ expect(
+ screen.getByRole('button', { name: 'Norway' }),
+ ).not.toHaveTextContent('(favoritt)');
+ });
+
+ it('viser melding og ingen landliste ved tomt utvalg', () => {
+ render(
+ false}
+ />,
+ );
+
+ expect(screen.getByText('Ingen land i utvalget.')).toBeInTheDocument();
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
+ });
+
+ it('lukker listen og flytter fokus til toggle-knappen etter valg', async () => {
+ const onSelect = vi.fn();
+ render(
+ false}
+ />,
+ );
+
+ const toggle = screen.getByRole('button', { name: /Landliste/ });
+ await userEvent.click(toggle);
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
+
+ await userEvent.click(screen.getByRole('button', { name: 'Sweden' }));
+
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ expect(toggle).toHaveFocus();
+ });
+});
diff --git a/src/components/CountryNavigation.test.tsx b/src/components/CountryNavigation.test.tsx
new file mode 100644
index 0000000..5d4606e
--- /dev/null
+++ b/src/components/CountryNavigation.test.tsx
@@ -0,0 +1,79 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+import { createMockCountry } from '../test/mockCountry';
+import CountryNavigation from './CountryNavigation';
+
+const countries = [
+ createMockCountry({ code: 'NOR', name: 'Norway' }),
+ createMockCountry({ code: 'SWE', name: 'Sweden' }),
+ createMockCountry({ code: 'DNK', name: 'Denmark' }),
+];
+
+describe('CountryNavigation', () => {
+ it('viser posisjon og går til neste land ved klikk', async () => {
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Land 2 av 3')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Neste' }));
+
+ expect(onSelect).toHaveBeenCalledWith('DNK');
+ });
+
+ it('går til forrige land ved klikk', async () => {
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Forrige' }));
+
+ expect(onSelect).toHaveBeenCalledWith('NOR');
+ });
+
+ it('deaktiverer «Forrige» ved første og «Neste» ved siste land', () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByRole('button', { name: 'Forrige' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Neste' })).toBeEnabled();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByRole('button', { name: 'Forrige' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Neste' })).toBeDisabled();
+ });
+
+ it('rendrer ingenting når valgt kode ikke finnes i utvalget', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/src/components/FavoriteButton.test.tsx b/src/components/FavoriteButton.test.tsx
index e6b9e50..0b61bae 100644
--- a/src/components/FavoriteButton.test.tsx
+++ b/src/components/FavoriteButton.test.tsx
@@ -35,4 +35,27 @@ describe('FavoriteButton', () => {
screen.getByRole('button', { name: 'Favoritt: Norge' }),
).toHaveAttribute('aria-pressed', 'true');
});
+
+ // FavoriteButton er liten og rent presentasjonsmessig, uten egen state.
+ // Markeringen fanger derfor uventede endringer i strukturen, ikke i logikken.
+ it('matcher snapshot i av- og på-tilstand', () => {
+ const off = render(
+ ,
+ );
+ expect(off.container).toMatchSnapshot('av');
+ off.unmount();
+
+ const on = render(
+ ,
+ );
+ expect(on.container).toMatchSnapshot('på');
+ });
});
diff --git a/src/components/__snapshots__/FavoriteButton.test.tsx.snap b/src/components/__snapshots__/FavoriteButton.test.tsx.snap
new file mode 100644
index 0000000..3a557d3
--- /dev/null
+++ b/src/components/__snapshots__/FavoriteButton.test.tsx.snap
@@ -0,0 +1,39 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`FavoriteButton > matcher snapshot i av- og på-tilstand > av 1`] = `
+
+
+
+`;
+
+exports[`FavoriteButton > matcher snapshot i av- og på-tilstand > på 1`] = `
+
+
+
+`;
diff --git a/src/hooks/useFavorites.test.ts b/src/hooks/useFavorites.test.ts
new file mode 100644
index 0000000..38c6865
--- /dev/null
+++ b/src/hooks/useFavorites.test.ts
@@ -0,0 +1,42 @@
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { FAVORITES_STORAGE_KEY } from '../utils/favoritesStorage';
+import { useFavorites } from './useFavorites';
+
+describe('useFavorites', () => {
+ it('starter uten favoritter når lagring er tom', () => {
+ const { result } = renderHook(() => useFavorites());
+
+ expect(result.current.isFavorite('NOR')).toBe(false);
+ });
+
+ it('legger til og fjerner favoritt, og lagrer endringen i localStorage', () => {
+ const { result } = renderHook(() => useFavorites());
+
+ act(() => {
+ result.current.toggleFavorite('NOR');
+ });
+ expect(result.current.isFavorite('NOR')).toBe(true);
+ expect(localStorage.getItem(FAVORITES_STORAGE_KEY)).toBe(
+ JSON.stringify(['NOR']),
+ );
+
+ act(() => {
+ result.current.toggleFavorite('NOR');
+ });
+ expect(result.current.isFavorite('NOR')).toBe(false);
+ expect(localStorage.getItem(FAVORITES_STORAGE_KEY)).toBe(
+ JSON.stringify([]),
+ );
+ });
+
+ it('gjenoppretter favoritter fra localStorage ved ny montering', () => {
+ localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(['BRA', 'BGD']));
+
+ const { result } = renderHook(() => useFavorites());
+
+ expect(result.current.isFavorite('BRA')).toBe(true);
+ expect(result.current.isFavorite('BGD')).toBe(true);
+ expect(result.current.isFavorite('NOR')).toBe(false);
+ });
+});
diff --git a/src/test/mockCountry.ts b/src/test/mockCountry.ts
new file mode 100644
index 0000000..6e0b076
--- /dev/null
+++ b/src/test/mockCountry.ts
@@ -0,0 +1,17 @@
+import type { Country } from '../types/country';
+
+// Et fullstendig land med faste verdier, til bruk som utgangspunkt i tester.
+// Overrides lar en test bare endre feltene den faktisk bryr seg om.
+export function createMockCountry(overrides: Partial = {}): Country {
+ return {
+ code: 'NOR',
+ name: 'Norway',
+ flagUrl: 'https://example.test/flags/nor.svg',
+ capital: 'Oslo',
+ region: 'Europe',
+ population: 5_400_000,
+ area: 385_207,
+ languages: ['Norwegian'],
+ ...overrides,
+ };
+}
diff --git a/src/utils/favoritesStorage.test.ts b/src/utils/favoritesStorage.test.ts
new file mode 100644
index 0000000..679ef9a
--- /dev/null
+++ b/src/utils/favoritesStorage.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ FAVORITES_STORAGE_KEY,
+ loadFavorites,
+ saveFavorites,
+} from './favoritesStorage';
+
+describe('loadFavorites', () => {
+ it('returnerer tom liste når ingenting er lagret', () => {
+ expect(loadFavorites()).toEqual([]);
+ });
+
+ it('leser en gyldig, lagret liste', () => {
+ localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(['NOR', 'SWE']));
+
+ expect(loadFavorites()).toEqual(['NOR', 'SWE']);
+ });
+
+ it('fjerner duplikater', () => {
+ localStorage.setItem(
+ FAVORITES_STORAGE_KEY,
+ JSON.stringify(['NOR', 'SWE', 'NOR']),
+ );
+
+ expect(loadFavorites()).toEqual(['NOR', 'SWE']);
+ });
+
+ it('hopper over verdier som ikke er tekst eller er tomme', () => {
+ localStorage.setItem(
+ FAVORITES_STORAGE_KEY,
+ JSON.stringify(['NOR', 7, null, '', { a: 1 }, 'SWE']),
+ );
+
+ expect(loadFavorites()).toEqual(['NOR', 'SWE']);
+ });
+
+ it('returnerer tom liste ved ugyldig JSON', () => {
+ localStorage.setItem(FAVORITES_STORAGE_KEY, 'ikke gyldig json');
+
+ expect(loadFavorites()).toEqual([]);
+ });
+
+ it('returnerer tom liste når lagret verdi ikke er en liste', () => {
+ localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify({ a: 1 }));
+
+ expect(loadFavorites()).toEqual([]);
+ });
+
+ it('returnerer tom liste når lesing kaster feil', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('blokkert');
+ });
+
+ expect(loadFavorites()).toEqual([]);
+ });
+});
+
+describe('saveFavorites', () => {
+ it('lagrer landkodene som en JSON-liste', () => {
+ saveFavorites(['NOR', 'SWE']);
+
+ expect(localStorage.getItem(FAVORITES_STORAGE_KEY)).toBe(
+ JSON.stringify(['NOR', 'SWE']),
+ );
+ });
+
+ it('kaster ikke feil når lagring er blokkert', () => {
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new Error('blokkert');
+ });
+
+ expect(() => saveFavorites(['NOR'])).not.toThrow();
+ });
+});
diff --git a/src/utils/sortCountries.test.ts b/src/utils/sortCountries.test.ts
new file mode 100644
index 0000000..64d6217
--- /dev/null
+++ b/src/utils/sortCountries.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest';
+import { createMockCountry } from '../test/mockCountry';
+import { sortCountries } from './sortCountries';
+
+// Navn som dekker norsk sortering: U kommer før Æ, Ø og Å.
+const countries = [
+ createMockCountry({ code: 'ALB', name: 'Albania', population: 200 }),
+ createMockCountry({ code: 'BRA', name: 'Brasil', population: 500 }),
+ createMockCountry({ code: 'UKJ', name: 'Ukjentland', population: undefined }),
+ createMockCountry({ code: 'OST', name: 'Østland', population: 300 }),
+ createMockCountry({ code: 'ALE', name: 'Ålesundia', population: 400 }),
+];
+
+describe('sortCountries', () => {
+ it('sorterer alfabetisk med norsk sortering', () => {
+ const sorted = sortCountries(countries, 'name');
+
+ expect(sorted.map((country) => country.name)).toEqual([
+ 'Albania',
+ 'Brasil',
+ 'Ukjentland',
+ 'Østland',
+ 'Ålesundia',
+ ]);
+ });
+
+ it('sorterer etter befolkning, synkende, med manglende verdi sist', () => {
+ const sorted = sortCountries(countries, 'population');
+
+ expect(sorted.map((country) => country.name)).toEqual([
+ 'Brasil',
+ 'Ålesundia',
+ 'Østland',
+ 'Albania',
+ 'Ukjentland',
+ ]);
+ });
+
+ it('bruker navn som tiebreak ved lik befolkning', () => {
+ const tied = [
+ createMockCountry({ code: 'BRV', name: 'Bravo', population: 100 }),
+ createMockCountry({ code: 'ALP', name: 'Alpha', population: 100 }),
+ ];
+
+ const sorted = sortCountries(tied, 'population');
+
+ expect(sorted.map((country) => country.name)).toEqual(['Alpha', 'Bravo']);
+ });
+
+ it('endrer ikke originallisten', () => {
+ const frozen = Object.freeze([...countries]);
+
+ expect(() => sortCountries(frozen, 'population')).not.toThrow();
+ expect(frozen.map((country) => country.name)).toEqual(
+ countries.map((country) => country.name),
+ );
+ });
+});
diff --git a/src/utils/sortOrderStorage.test.ts b/src/utils/sortOrderStorage.test.ts
new file mode 100644
index 0000000..af56507
--- /dev/null
+++ b/src/utils/sortOrderStorage.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it, vi } from 'vitest';
+import { DEFAULT_SORT_ORDER } from './sortCountries';
+import {
+ SORT_ORDER_STORAGE_KEY,
+ loadSortOrder,
+ saveSortOrder,
+} from './sortOrderStorage';
+
+describe('loadSortOrder', () => {
+ it('returnerer standardvalget når ingenting er lagret', () => {
+ expect(loadSortOrder()).toBe(DEFAULT_SORT_ORDER);
+ });
+
+ it('leser en gyldig, lagret sortering', () => {
+ sessionStorage.setItem(SORT_ORDER_STORAGE_KEY, 'population');
+
+ expect(loadSortOrder()).toBe('population');
+ });
+
+ it('returnerer standardvalget ved en ugyldig lagret verdi', () => {
+ sessionStorage.setItem(SORT_ORDER_STORAGE_KEY, 'hacket');
+
+ expect(loadSortOrder()).toBe(DEFAULT_SORT_ORDER);
+ });
+
+ it('returnerer standardvalget når lesing kaster feil', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('blokkert');
+ });
+
+ expect(loadSortOrder()).toBe(DEFAULT_SORT_ORDER);
+ });
+});
+
+describe('saveSortOrder', () => {
+ it('lagrer sorteringen', () => {
+ saveSortOrder('population');
+
+ expect(sessionStorage.getItem(SORT_ORDER_STORAGE_KEY)).toBe('population');
+ });
+
+ it('kaster ikke feil når lagring er blokkert', () => {
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new Error('blokkert');
+ });
+
+ expect(() => saveSortOrder('name')).not.toThrow();
+ });
+});
diff --git a/vite.config.ts b/vite.config.ts
index 051fdd7..f653c6c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -9,5 +9,8 @@ export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
+ // Gjenbruker jsdom-miljøet per worker i stedet for å lage det på nytt
+ // for hver testfil, uten å gi opp isolasjon mellom filene.
+ pool: 'vmThreads',
},
});