// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
const I18N = { t: (k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? k, i18n: { language: 'es' } }
vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
import { Icon, DynamicIcon } from '../dynamic-icon'
import { registerIconResolver, resolveIconSpec } from '../icon-resolvers'
import { DynamicTable } from '../dynamic-table'
import { ApiProvider, type ApiClient } from '../api-context'
import { useMetadataCache } from '../metadata-cache'
import type { TableMetadata } from '../types'
afterEach(cleanup)
describe('registerIconResolver', () => {
it('el resolutor del host gana a los built-ins y el disposer lo retira', () => {
const dispose = registerIconResolver((s) => (s === 'fas fa-brain' ? { kind: 'emoji', value: '🧠' } : null))
expect(resolveIconSpec('fas fa-brain')).toEqual({ kind: 'emoji', value: '🧠' })
expect(resolveIconSpec('fas fa-bone')).toEqual({ kind: 'lucide', name: 'Bone' })
dispose()
expect(resolveIconSpec('fas fa-brain')).toEqual({ kind: 'lucide', name: 'Brain' })
})
it('el más reciente tiene prioridad; null cede al siguiente', () => {
const a = registerIconResolver(() => ({ kind: 'emoji', value: 'A' }))
const b = registerIconResolver((s) => (s === 'x' ? { kind: 'emoji', value: 'B' } : null))
expect(resolveIconSpec('x')).toEqual({ kind: 'emoji', value: 'B' })
expect(resolveIconSpec('y')).toEqual({ kind: 'emoji', value: 'A' })
b()
expect(resolveIconSpec('x')).toEqual({ kind: 'emoji', value: 'A' })
a()
})
it('un resolutor que lanza se ignora; un ReactNode se renderiza tal cual', () => {
const a = registerIconResolver(() => {
throw new Error('boom')
})
const b = registerIconResolver((s) => (s === 'sprite:x' ? : null))
expect(resolveIconSpec('Brain')).toEqual({ kind: 'lucide', name: 'Brain' })
render()
expect(screen.getByTestId('sprite')).toBeTruthy()
a()
b()
})
})
describe('Icon', () => {
it('lucide: svg, decorativo por defecto', async () => {
const { container } = render()
await waitFor(() => expect(container.querySelector('svg')).toBeTruthy())
expect(container.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true')
})
it('label vuelve el icono accesible', async () => {
const { container } = render()
await waitFor(() => expect(container.querySelector('svg')).toBeTruthy())
expect(container.querySelector('svg')?.getAttribute('aria-label')).toBe('Cerebro')
})
it('emoji: span aria-hidden', () => {
const { container } = render()
const span = container.querySelector('span')!
expect(span.textContent).toBe('🩺')
expect(span.getAttribute('aria-hidden')).toBe('true')
})
it('image: img lazy con alt vacío y se oculta si falla', () => {
const { container } = render()
const img = container.querySelector('img')!
expect(img.getAttribute('src')).toBe('https://x.test/a.png')
expect(img.getAttribute('alt')).toBe('')
expect(img.getAttribute('loading')).toBe('lazy')
expect(img.getAttribute('decoding')).toBe('async')
fireEvent.error(img)
expect(container.querySelector('img')).toBeNull()
})
it('image: resolveImageSrc transforma la ruta', () => {
const { container } = render( `/api${s}`} />)
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/u/a.png')
})
it('unknown: nunca texto crudo; fallback opcional', () => {
const a = render()
expect(a.container.textContent).toBe('')
a.unmount()
const b = render(} />)
expect(screen.getByTestId('fb')).toBeTruthy()
expect(b.container.querySelector('img')).toBeNull()
})
it('DynamicIcon conserva su contrato con nombres Lucide', async () => {
const { container } = render()
expect(container.firstElementChild?.getAttribute('class')).toContain('mr-2 h-4 w-4')
await waitFor(() => expect(container.querySelector('svg:not([data-glyph-pending])')).toBeTruthy())
})
})
describe('DynamicTable columna type "icon"', () => {
const metaIcon: TableMetadata = {
title: 'Specialties',
endpoint: '/data/specialty',
columns: [
{ key: 'name', label: 'Nombre', type: 'text', sortable: false, filterable: false, searchable: false },
{ key: 'icon', label: 'Icono', type: 'icon', sortable: false, filterable: false, searchable: false },
],
actions: [],
perPageOptions: [10],
defaultPerPage: 10,
searchPlaceholder: 'Buscar...',
enableCRUDActions: false,
hasActions: false,
}
const rows = [
{ id: 1, name: 'Neurología', icon: 'fas fa-brain' },
{ id: 2, name: 'Raro', icon: 'no se entiende' },
{ id: 3, name: 'Vacio', icon: '' },
]
const api: ApiClient = {
get: vi.fn(async (url: string) => {
if (url.startsWith('/metadata/table/')) return { data: { success: true, data: metaIcon } }
if (url.endsWith('/facets')) return { data: { success: true, data: [] } }
return { data: { success: true, data: rows, meta: { total: rows.length } } }
}),
post: vi.fn(async () => ({ data: { success: true, data: null } })),
put: vi.fn(async () => ({ data: { success: true, data: null } })),
delete: vi.fn(async () => ({ data: { success: true, data: null } })),
}
it('"fas fa-brain" dibuja un glifo y nunca el texto crudo', async () => {
useMetadataCache.getState().setMetadata('specialty', metaIcon)
const { container } = render(
,
)
await waitFor(() => expect(screen.getAllByText('Neurología').length).toBeGreaterThan(0))
expect(container.textContent).not.toContain('fas fa-brain')
expect(container.textContent).not.toContain('no se entiende')
await waitFor(() => expect(container.querySelector('tbody svg.lucide:not([data-glyph-pending])')).toBeTruthy())
})
})