@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
315 lines (279 loc) • 11.8 kB
text/typescript
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()
})
})