import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Avatar } from './avatar';
import {
AVATAR_ERROR_EVENT,
AVATAR_LOAD_EVENT,
AVATAR_STATUSCHANGE_EVENT,
type AvatarStatusChangeEvent,
} from './avatar.types';
function setup(markup: string): HTMLElement {
document.body.innerHTML = markup;
return document.body.firstElementChild as HTMLElement;
}
const FULL = `
`;
function image(root: HTMLElement): HTMLImageElement {
return root.querySelector('[data-c42-avatar-image]')!;
}
function fallback(root: HTMLElement): HTMLElement {
return root.querySelector('[data-c42-avatar-fallback]')!;
}
function statusEl(root: HTMLElement): HTMLElement {
return root.querySelector('[data-c42-avatar-status]')!;
}
describe('Avatar', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('derives initials from the image alt when fallback is empty', () => {
const root = setup(FULL);
new Avatar(root);
expect(fallback(root).textContent).toBe('JD');
});
it('derives initials from the name option over alt', () => {
const root = setup(FULL);
new Avatar(root, { name: 'Ada Lovelace King' });
expect(fallback(root).textContent).toBe('AL');
});
it('starts in the loading state and transitions to loaded on img load', () => {
const root = setup(FULL);
new Avatar(root);
expect(root.dataset.state).toBe('loading');
image(root).dispatchEvent(new Event('load'));
expect(root.dataset.state).toBe('loaded');
});
it('transitions to error on img error', () => {
const root = setup(FULL);
new Avatar(root);
image(root).dispatchEvent(new Event('error'));
expect(root.dataset.state).toBe('error');
});
it('emits avatar:load on load', () => {
const root = setup(FULL);
const avatar = new Avatar(root);
const spy = vi.fn();
avatar.on(AVATAR_LOAD_EVENT, spy);
image(root).dispatchEvent(new Event('load'));
expect(spy).toHaveBeenCalledOnce();
});
it('emits avatar:error on error', () => {
const root = setup(FULL);
const avatar = new Avatar(root);
const spy = vi.fn();
avatar.on(AVATAR_ERROR_EVENT, spy);
image(root).dispatchEvent(new Event('error'));
expect(spy).toHaveBeenCalledOnce();
});
it('goes to error immediately when the image has no src', () => {
const root = setup(`
`);
new Avatar(root);
expect(root.dataset.state).toBe('error');
expect(fallback(root).textContent).toBe('NS');
});
it('goes to error with only a fallback element (no image)', () => {
const root = setup(`
`);
new Avatar(root, { name: 'Grace Hopper' });
expect(root.dataset.state).toBe('error');
expect(fallback(root).textContent).toBe('GH');
});
it('reflects an initial status on the status element', () => {
const root = setup(FULL);
new Avatar(root, { status: 'online' });
expect(statusEl(root).dataset.status).toBe('online');
expect(statusEl(root).getAttribute('aria-label')).toBe('online');
});
it('updates status and emits avatar:statuschange via setStatus', () => {
const root = setup(FULL);
const avatar = new Avatar(root);
const spy = vi.fn();
avatar.on(AVATAR_STATUSCHANGE_EVENT, spy);
avatar.setStatus('busy');
expect(statusEl(root).dataset.status).toBe('busy');
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].detail.status).toBe('busy');
});
it('clears the status when set to null', () => {
const root = setup(FULL);
const avatar = new Avatar(root, { status: 'away' });
avatar.setStatus(null);
expect(statusEl(root).dataset.status).toBeUndefined();
expect(statusEl(root).hasAttribute('aria-label')).toBe(false);
});
it('resets to loading on setSrc', () => {
const root = setup(FULL);
const avatar = new Avatar(root);
image(root).dispatchEvent(new Event('load'));
expect(root.dataset.state).toBe('loaded');
avatar.setSrc('other.png');
expect(root.dataset.state).toBe('loading');
expect(image(root).getAttribute('src')).toBe('other.png');
});
it('sets role and aria-label from the name', () => {
const root = setup(FULL);
new Avatar(root, { name: 'Jane Doe' });
expect(root.getAttribute('role')).toBe('img');
expect(root.getAttribute('aria-label')).toBe('Jane Doe');
});
it('throws when neither image nor fallback is present', () => {
const root = setup('');
expect(() => new Avatar(root)).toThrow(/\[42\/avatar\]/);
});
it('removes listeners on destroy', () => {
const root = setup(FULL);
const avatar = new Avatar(root);
const spy = vi.fn();
avatar.on(AVATAR_LOAD_EVENT, spy);
avatar.destroy();
image(root).dispatchEvent(new Event('load'));
expect(spy).not.toHaveBeenCalled();
});
});