add endpoint configuration for city, currency, language, and timezone
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Observable, Subject, of, throwError } from 'rxjs';
|
||||
|
||||
import { Autocomplete } from './autocomplete';
|
||||
import { 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: `
|
||||
<app-autocomplete
|
||||
inputId="country"
|
||||
label="Country"
|
||||
[formControl]="control"
|
||||
[searchFn]="searchFn"
|
||||
[displayWith]="displayWith"
|
||||
[valueWith]="valueWith"
|
||||
[selectedItem]="selectedItem()"
|
||||
[minSearchLength]="minLength"
|
||||
[debounceTime]="delay()"
|
||||
[readonly]="readonly()"
|
||||
[showDropdownOnFocus]="openOnFocus"
|
||||
/>
|
||||
`
|
||||
})
|
||||
class HostComponent {
|
||||
readonly control = new FormControl<string | null>(null);
|
||||
readonly selectedItem = signal<LookupItem | null>(null);
|
||||
searchFn: AutocompleteSearchFn<LookupItem> = () => 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);
|
||||
openOnFocus = false;
|
||||
}
|
||||
|
||||
describe('Autocomplete', () => {
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
let host: HostComponent;
|
||||
let component: Autocomplete<LookupItem, string>;
|
||||
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([]);
|
||||
});
|
||||
|
||||
it('integrates with a reactive form control', () => {
|
||||
component.select(INDIA);
|
||||
expect(host.control.value).toBe('IN');
|
||||
});
|
||||
|
||||
it('does not emit onChange from writeValue', () => {
|
||||
const change = vi.fn();
|
||||
component.registerOnChange(change);
|
||||
component.writeValue('IN');
|
||||
expect(change).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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('cancels stale search requests', () => {
|
||||
vi.useFakeTimers();
|
||||
const first = new Subject<readonly LookupItem[]>();
|
||||
const second = new Subject<readonly LookupItem[]>();
|
||||
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<readonly LookupItem[]> =>
|
||||
++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<HTMLButtonElement>('[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 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<HTMLElement>('.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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user