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(); }); });