import { OverlayContainer } from '@angular/cdk/overlay';
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { Observable, Subject, of, throwError } from 'rxjs';
import { Autocomplete } from './autocomplete';
import { AutocompleteResolveValueFn, AutocompleteSearchFn } from './autocomplete.types';
interface LookupItem {
readonly code: string;
readonly title: string;
}
const INDIA: LookupItem = { code: 'IN', title: 'India' };
const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' };
@Component({
standalone: true,
imports: [ReactiveFormsModule, Autocomplete],
template: `
`
})
class HostComponent {
readonly control = new FormControl(null);
readonly selectedItem = signal(null);
readonly resolveValueFn = signal | null>(null);
searchFn: AutocompleteSearchFn = () => of([INDIA, INDONESIA]);
readonly displayWith = (item: LookupItem): string => item.title;
readonly valueWith = (item: LookupItem): string => item.code;
minLength = 2;
readonly delay = signal(300);
readonly readonly = signal(false);
readonly openOnFocus = signal(false);
readonly submitAttempted = signal(false);
}
describe('Autocomplete', () => {
let fixture: ComponentFixture;
let host: HostComponent;
let component: Autocomplete;
let overlayContainer: OverlayContainer;
const input = (): HTMLInputElement => fixture.nativeElement.querySelector('input');
const type = (value: string): void => {
input().value = value;
input().dispatchEvent(new Event('input'));
fixture.detectChanges();
};
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents();
fixture = TestBed.createComponent(HostComponent);
host = fixture.componentInstance;
overlayContainer = TestBed.inject(OverlayContainer);
fixture.detectChanges();
component = fixture.debugElement.children[0].componentInstance;
});
afterEach(() => overlayContainer.ngOnDestroy());
it('initializes with an empty selection', () => {
expect(component.searchText()).toBe('');
expect(component.options()).toEqual([]);
expect(input().value).toBe('');
expect(input().placeholder).toBe('e.g.: USD');
expect(input().className).not.toContain('ti-form-control');
expect(input().className).toContain('placeholder:text-textmuted');
expect(input().className).toContain('dark:placeholder:text-white/50');
});
it('integrates with a reactive form control', () => {
component.select(INDIA);
expect(host.control.value).toBe('IN');
});
it('treats edited selected text as search text rather than a selected item', () => {
component.select(INDIA);
expect(component.activeItem()).toEqual(INDIA);
type('Ind');
expect(component.searchText()).toBe('Ind');
expect(component.activeItem()).toBeNull();
});
it('does not emit onChange from writeValue', () => {
const change = vi.fn();
component.registerOnChange(change);
component.writeValue('IN');
expect(change).not.toHaveBeenCalled();
});
it('does not show invalid styling after touch before submit', () => {
host.control.setValidators(Validators.required);
host.control.updateValueAndValidity();
host.control.markAsTouched();
fixture.detectChanges();
expect(input().classList.contains('is-invalid')).toBe(false);
});
it('shows invalid styling after submit and removes it when valid', () => {
host.control.setValidators(Validators.required);
host.control.updateValueAndValidity();
host.submitAttempted.set(true);
fixture.detectChanges();
expect(input().classList.contains('is-invalid')).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Country is required.');
host.control.setValue('IN');
fixture.detectChanges();
expect(input().classList.contains('is-invalid')).toBe(false);
expect(fixture.nativeElement.textContent).not.toContain('Country is required.');
});
it('applies a disabled form state', () => {
host.control.disable();
fixture.detectChanges();
expect(input().disabled).toBe(true);
});
it('waits for the configured debounce', () => {
vi.useFakeTimers();
const search = vi.fn(() => of([INDIA]));
host.searchFn = search;
fixture.detectChanges();
type('in');
vi.advanceTimersByTime(299);
expect(search).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(search).toHaveBeenCalledWith('in', 10);
vi.useRealTimers();
});
it('does not search below the minimum length', () => {
vi.useFakeTimers();
const search = vi.fn(() => of([INDIA]));
host.searchFn = search;
fixture.detectChanges();
type('i');
vi.advanceTimersByTime(300);
expect(search).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('loads five preview records on focus before the user types', () => {
vi.useFakeTimers();
const search = vi.fn(() => of([INDIA, INDONESIA]));
host.openOnFocus.set(true);
host.searchFn = search;
fixture.detectChanges();
input().focus();
vi.advanceTimersByTime(0);
fixture.detectChanges();
expect(search).toHaveBeenCalledWith('', 5);
expect(component.options()).toEqual([INDIA, INDONESIA]);
expect(component.showingPreview()).toBe(true);
expect(overlayContainer.getContainerElement().textContent).toContain('Type to search more...');
vi.useRealTimers();
});
it('replaces preview records with typed search results', () => {
vi.useFakeTimers();
host.openOnFocus.set(true);
host.delay.set(0);
host.searchFn = (term, _limit) => of(term ? [INDONESIA] : [INDIA]);
fixture.detectChanges();
input().focus();
vi.advanceTimersByTime(0);
expect(component.options()).toEqual([INDIA]);
type('in');
vi.advanceTimersByTime(0);
fixture.detectChanges();
expect(component.options()).toEqual([INDONESIA]);
expect(component.showingPreview()).toBe(false);
expect(overlayContainer.getContainerElement().textContent).not.toContain('Type to search more...');
vi.useRealTimers();
});
it('cancels stale search requests', () => {
vi.useFakeTimers();
const first = new Subject();
const second = new Subject();
host.delay.set(0);
host.searchFn = term => term === 'in' ? first : second;
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
type('ind'); vi.advanceTimersByTime(0);
first.next([INDONESIA]);
second.next([INDIA]);
expect(component.options()).toEqual([INDIA]);
vi.useRealTimers();
});
it('resets loading after a successful search', () => {
vi.useFakeTimers();
host.delay.set(0);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
expect(component.loading()).toBe(false);
vi.useRealTimers();
});
it('resets loading after an error', () => {
vi.useFakeTimers();
host.delay.set(0);
host.searchFn = () => throwError(() => new Error('server'));
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
expect(component.loading()).toBe(false);
expect(component.error()).toBe('Unable to load results');
vi.useRealTimers();
});
it('recovers on a later search after an error', () => {
vi.useFakeTimers();
let attempts = 0;
host.delay.set(0);
host.searchFn = (): Observable =>
++attempts === 1 ? throwError(() => new Error('server')) : of([INDIA]);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
type('ind'); vi.advanceTimersByTime(0);
expect(component.options()).toEqual([INDIA]);
expect(component.error()).toBeNull();
vi.useRealTimers();
});
it('renders returned options', () => {
vi.useFakeTimers();
host.delay.set(0);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0); fixture.detectChanges();
expect(overlayContainer.getContainerElement().textContent).toContain('India');
vi.useRealTimers();
});
it('selects an option with the mouse', () => {
vi.useFakeTimers();
host.delay.set(0);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0); fixture.detectChanges();
overlayContainer.getContainerElement().querySelector('[role="option"]')?.click();
expect(host.control.value).toBe('IN');
vi.useRealTimers();
});
it('selects the highlighted option with Enter', () => {
component.options.set([INDIA]);
component.open();
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(host.control.value).toBe('IN');
});
it('only highlights on arrow navigation', () => {
component.options.set([INDIA]);
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(component.activeIndex()).toBe(0);
expect(host.control.value).toBeNull();
});
it('closes on Escape', () => {
component.open();
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(component.isOpen()).toBe(false);
});
it('clears the form value to null', () => {
component.select(INDIA);
component.clear();
expect(host.control.value).toBeNull();
});
it('clears visible state when the form resets', () => {
component.select(INDIA);
host.control.reset();
fixture.detectChanges();
expect(component.searchText()).toBe('');
expect(component.activeItem()).toBeNull();
});
it('displays an existing selected item', () => {
host.selectedItem.set(INDIA);
host.control.setValue('IN');
fixture.detectChanges();
expect(component.searchText()).toBe('India');
});
it('does not resolve an empty string form value', () => {
const resolve = vi.fn(() => of(INDIA));
host.resolveValueFn.set(resolve);
fixture.detectChanges();
host.control.setValue('');
fixture.detectChanges();
expect(resolve).not.toHaveBeenCalled();
});
it('uses the matching selected item without resolving it again', () => {
const resolve = vi.fn(() => of(INDIA));
host.resolveValueFn.set(resolve);
host.selectedItem.set(INDIA);
fixture.detectChanges();
host.control.setValue('IN');
fixture.detectChanges();
expect(component.searchText()).toBe('India');
expect(resolve).not.toHaveBeenCalled();
});
it('resolves a non-empty value when no matching selected item is supplied', () => {
const resolve = vi.fn(() => of(INDIA));
host.resolveValueFn.set(resolve);
fixture.detectChanges();
host.control.setValue('IN');
fixture.detectChanges();
expect(resolve).toHaveBeenCalledWith('IN');
expect(component.searchText()).toBe('India');
});
it('does not retain a stale label when edit values change', () => {
host.selectedItem.set(INDIA);
host.control.setValue('IN');
fixture.detectChanges();
host.control.setValue('ID');
fixture.detectChanges();
expect(component.searchText()).toBe('');
});
it('closes when the overlay backdrop is clicked', () => {
component.open();
fixture.detectChanges();
overlayContainer.getContainerElement().querySelector('.cdk-overlay-backdrop')?.click();
expect(component.isOpen()).toBe(false);
});
it('prevents modification in read-only mode', () => {
host.readonly.set(true);
fixture.detectChanges();
component.select(INDIA);
expect(host.control.value).toBeNull();
});
it('prevents interaction while disabled', () => {
host.control.disable();
fixture.detectChanges();
component.open();
expect(component.isOpen()).toBe(false);
});
it('updates combobox ARIA state', () => {
component.open();
fixture.detectChanges();
expect(input().getAttribute('role')).toBe('combobox');
expect(input().getAttribute('aria-expanded')).toBe('true');
expect(input().getAttribute('aria-controls')).toBe('country-listbox');
});
it('renders the empty state', () => {
vi.useFakeTimers();
host.delay.set(0);
host.searchFn = () => of([]);
fixture.detectChanges();
type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges();
expect(overlayContainer.getContainerElement().textContent).toContain('No results found');
vi.useRealTimers();
});
it('renders the friendly error state', () => {
vi.useFakeTimers();
host.delay.set(0);
host.searchFn = () => throwError(() => new Error('raw error'));
fixture.detectChanges();
type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges();
const text = overlayContainer.getContainerElement().textContent ?? '';
expect(text).toContain('Unable to load results');
expect(text).not.toContain('raw error');
vi.useRealTimers();
});
});