UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

315 lines (279 loc) 11.8 kB
import '@testing-library/jest-dom' import { axe, toHaveNoViolations } from 'jest-axe' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' expect.extend(toHaveNoViolations) import './header-menu' import { PktHeaderMenu } from './header-menu' import { deriveSocialIcon, ICON_MAP_ODS_TO_PUNKT, mapOdsIcon, selectLocaleData, selectMegamenu, } from 'shared-utils/header-menu' import type { THeaderFooterApi } from 'shared-types/header-menu' const LOCAL_DATA: THeaderFooterApi = { 'nb-NO': { megamenu: { buttons: [{ text: 'Min side', url: 'https://www.oslo.kommune.no/min-side/' }], services: { title: 'Tjenester og tilbud', links: [ { icon: '24h', text: 'Døgnåpne tjenester', url: 'https://example.test/24h' }, { icon: 'fire-emblem', text: 'Brannvern', url: 'https://example.test/brannvern' }, { icon: 'unknown-icon', text: 'Ukjent', url: 'https://example.test/ukjent' }, ], }, sections: [ { title: 'Oslo vokser', links: [{ text: 'Byutvikling', url: 'https://example.test/byutvikling' }], }, { title: 'Politikk og innsyn', links: [{ text: 'Politikk', url: 'https://example.test/politikk' }], }, ], links: [ { text: 'Kontakt', url: 'https://example.test/kontakt' }, { text: 'English', url: 'https://example.test/english' }, ], some: [ { text: 'Facebook', url: 'https://www.facebook.com/Oslo/' }, { text: 'LinkedIn', url: 'https://www.linkedin.com/company/oslo-kommune' }, ], }, footer: { sections: [], links: [], some: [] }, i18n: { menu: 'Meny', search: 'Søk', navAriaLabel: 'Oslo kommune hovedmeny' }, }, 'en-GB': { megamenu: { services: { title: 'Services and information', links: [{ icon: '24h', text: '24h', url: 'https://example.test/en/24h' }], }, sections: [], links: [], some: [], }, footer: { sections: [], links: [], some: [] }, i18n: { menu: 'Menu', search: 'Search', navAriaLabel: 'Oslo kommune main menu' }, }, } const waitForReady = async () => { await customElements.whenDefined('pkt-header-menu') } const mountWithData = async (props: Record<string, unknown> = {}): Promise<PktHeaderMenu> => { const el = document.createElement('pkt-header-menu') as PktHeaderMenu el.data = LOCAL_DATA el.open = true Object.assign(el, props) document.body.appendChild(el) await waitForReady() await el.updateComplete // Give nested icons/accordions a tick to settle. await new Promise((resolve) => setTimeout(resolve, 0)) await el.updateComplete return el } afterEach(() => { document.body.innerHTML = '' vi.restoreAllMocks() }) describe('shared-utils: icon-map', () => { test('mapOdsIcon translates known legacy names', () => { expect(mapOdsIcon('fire-emblem')).toBe('shield-fire') expect(mapOdsIcon('document')).toBe('document-plain') expect(mapOdsIcon('linked-in')).toBe('linkedin') }) test('mapOdsIcon passes unknown names through unchanged', () => { expect(mapOdsIcon('totally-made-up')).toBe('totally-made-up') }) test('mapOdsIcon handles empty input', () => { expect(mapOdsIcon('')).toBe('') }) test('ICON_MAP_ODS_TO_PUNKT only stores actual renames', () => { for (const [from, to] of Object.entries(ICON_MAP_ODS_TO_PUNKT)) { expect(from).not.toBe(to) } }) test('mapOdsIcon resolves the icons the live API ships', () => { // Identity passes (already valid Punkt names) and renames both reach // a non-empty mapped value. const liveIcons = ['24h', 'swingset', 'backpack', 'heart-plus', 'crane', 'fire-emblem'] for (const icon of liveIcons) { expect(mapOdsIcon(icon)).toBeTruthy() } }) test('deriveSocialIcon resolves known hostnames from the URL', () => { expect(deriveSocialIcon('https://www.facebook.com/Oslo/')).toBe('facebook') expect(deriveSocialIcon('https://www.instagram.com/oslo.kommune/')).toBe('instagram') expect(deriveSocialIcon('https://www.linkedin.com/company/oslo-kommune')).toBe('linkedin') expect(deriveSocialIcon('https://x.com/oslo_kommune')).toBe('x') }) test('deriveSocialIcon strips www./m. subdomains', () => { expect(deriveSocialIcon('https://m.facebook.com/Oslo/')).toBe('facebook') expect(deriveSocialIcon('https://facebook.com/Oslo/')).toBe('facebook') }) test('deriveSocialIcon ignores localized text when URL resolves', () => { expect( deriveSocialIcon('https://www.facebook.com/Oslo/', 'Følg oss på Facebook'), ).toBe('facebook') }) test('deriveSocialIcon falls back to text when the URL is unknown or invalid', () => { expect(deriveSocialIcon('mailto:foo@example.com', 'Facebook')).toBe('facebook') expect(deriveSocialIcon('not-a-url', 'LinkedIn')).toBe('linkedin') }) test('deriveSocialIcon falls back to slugified text for unknown platforms', () => { expect(deriveSocialIcon(undefined, 'Threads')).toBe('threads') }) test('deriveSocialIcon returns undefined when both inputs are empty', () => { expect(deriveSocialIcon()).toBeUndefined() expect(deriveSocialIcon('', '')).toBeUndefined() expect(deriveSocialIcon('not-a-url', ' ')).toBeUndefined() }) }) describe('shared-utils: select-megamenu', () => { test('selectLocaleData returns the requested locale slice', () => { expect(selectLocaleData(LOCAL_DATA, 'en-GB')?.i18n?.navAriaLabel).toBe('Oslo kommune main menu') }) test('selectLocaleData falls back to nb-NO for missing locales', () => { expect(selectLocaleData(LOCAL_DATA, 'fr-FR')?.i18n?.navAriaLabel).toBe('Oslo kommune hovedmeny') }) test('selectMegamenu returns the megamenu slice for the locale', () => { expect(selectMegamenu(LOCAL_DATA, 'nb-NO')?.services.title).toBe('Tjenester og tilbud') expect(selectMegamenu(LOCAL_DATA, 'en-GB')?.services.title).toBe('Services and information') }) test('selectLocaleData returns undefined for missing input', () => { expect(selectLocaleData(undefined)).toBeUndefined() }) }) describe('PktHeaderMenu rendering', () => { test('renders services, sections, and footer when given pre-fetched data', async () => { const el = await mountWithData() expect(el.querySelector('.pkt-header-menu__services-title')?.textContent).toContain( 'Tjenester og tilbud', ) expect(el.querySelectorAll('.pkt-header-menu__service')).toHaveLength(3) expect(el.querySelectorAll('.pkt-header-menu__section')).toHaveLength(2) expect(el.querySelectorAll('.pkt-header-menu__footer-link')).toHaveLength(2) }) test('does not fetch when data prop is supplied', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('should not run')) await mountWithData() expect(fetchSpy).not.toHaveBeenCalled() }) test('maps ODS icon names on service links', async () => { const el = await mountWithData() const serviceIcons = el.querySelectorAll<HTMLElement>('.pkt-header-menu__service-icon') expect(serviceIcons[0].getAttribute('name')).toBe('24h') expect(serviceIcons[1].getAttribute('name')).toBe('shield-fire') // Unmapped names pass through expect(serviceIcons[2].getAttribute('name')).toBe('unknown-icon') }) test('uses the locale-specific nav aria-label', async () => { const el = await mountWithData() const nav = el.querySelector('nav') expect(nav?.getAttribute('aria-label')).toBe('Oslo kommune hovedmeny') }) test('switches to en-GB content when locale changes', async () => { const el = await mountWithData() el.locale = 'en-GB' await el.updateComplete const nav = el.querySelector('nav') expect(nav?.getAttribute('aria-label')).toBe('Oslo kommune main menu') expect(el.querySelector('.pkt-header-menu__services-title')?.textContent).toContain( 'Services and information', ) }) test('renders social links with derived aria-labels and icons', async () => { const el = await mountWithData() const socialLinks = el.querySelectorAll<HTMLElement>('.pkt-header-menu__social-link') expect(socialLinks).toHaveLength(2) expect(socialLinks[0].getAttribute('aria-label')).toBe('Facebook') expect(socialLinks[0].querySelector('pkt-icon')?.getAttribute('name')).toBe('facebook') expect(socialLinks[1].getAttribute('aria-label')).toBe('LinkedIn') expect(socialLinks[1].querySelector('pkt-icon')?.getAttribute('name')).toBe('linkedin') }) test('reflects the open prop to a host class', async () => { const el = await mountWithData() expect(el.classList.contains('pkt-header-menu')).toBe(true) expect(el.classList.contains('pkt-header-menu--open')).toBe(true) el.open = false await el.updateComplete expect(el.classList.contains('pkt-header-menu--open')).toBe(false) }) test('emits data-loaded with the supplied payload', async () => { const el = document.createElement('pkt-header-menu') as PktHeaderMenu el.data = LOCAL_DATA const events: CustomEvent[] = [] el.addEventListener('data-loaded', (e) => events.push(e as CustomEvent)) document.body.appendChild(el) await waitForReady() await el.updateComplete expect(events.length).toBeGreaterThanOrEqual(1) expect(events[0].detail.data).toBe(LOCAL_DATA) }) }) describe('PktHeaderMenu fetching', () => { beforeEach(() => { vi.spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve( new Response(JSON.stringify(LOCAL_DATA), { status: 200, headers: { 'Content-Type': 'application/json' }, }), ), ) }) test('fetches the default URL on connect when no data is supplied', async () => { const el = document.createElement('pkt-header-menu') as PktHeaderMenu el.open = true document.body.appendChild(el) await waitForReady() // Wait for the async fetch chain to resolve. await new Promise((resolve) => setTimeout(resolve, 0)) await el.updateComplete expect(globalThis.fetch).toHaveBeenCalledWith( 'https://cdn.web.oslo.kommune.no/header-footer/header-footer.json', expect.any(Object), ) expect(el.querySelector('.pkt-header-menu__services-title')?.textContent).toContain( 'Tjenester og tilbud', ) }) test('honors a custom data-url attribute', async () => { const el = document.createElement('pkt-header-menu') as PktHeaderMenu el.setAttribute('data-url', 'https://custom.test/payload.json') document.body.appendChild(el) await waitForReady() await new Promise((resolve) => setTimeout(resolve, 0)) expect(globalThis.fetch).toHaveBeenCalledWith( 'https://custom.test/payload.json', expect.any(Object), ) }) test('emits data-error when the fetch fails', async () => { vi.restoreAllMocks() vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response('boom', { status: 500 }) as unknown as Response, ) const el = document.createElement('pkt-header-menu') as PktHeaderMenu const events: CustomEvent[] = [] el.addEventListener('data-error', (e) => events.push(e as CustomEvent)) document.body.appendChild(el) await waitForReady() await new Promise((resolve) => setTimeout(resolve, 0)) await el.updateComplete expect(events.length).toBe(1) expect((events[0].detail.error as Error).message).toMatch(/HTTP 500/) expect(el.querySelector('.pkt-header-menu__error')).not.toBeNull() }) }) describe('PktHeaderMenu accessibility', () => { test('has no axe violations with the local-data payload', async () => { const el = await mountWithData() const results = await axe(el) expect(results).toHaveNoViolations() }) })