import { DOCUMENT } from '@angular/common'; import { ApplicationRef, PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; import { SIGNATURE_COPY } from '../../core/content/signature-copy'; import { SITE_CONFIG } from '../../core/content/site-config'; import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale'; import { LocaleService } from '../../core/i18n/locale.service'; import { NavigationService } from '../../core/navigation/navigation.service'; import { CommandPalette } from './command-palette'; import { COMMAND_IDS, COMMANDS } from './commands'; describe('CommandPalette', () => { afterEach(() => { vi.restoreAllMocks(); }); async function createFixture( locale: AppLocale = 'de', extraProviders: { provide: unknown; useValue: unknown }[] = [], ): Promise> { TestBed.resetTestingModule(); await TestBed.configureTestingModule({ imports: [CommandPalette], providers: [provideRouter([]), ...extraProviders], }).compileComponents(); TestBed.inject(LocaleService).setLocale(locale); const fixture = TestBed.createComponent(CommandPalette); fixture.detectChanges(); await fixture.whenStable(); return fixture; } async function flush(fixture: ComponentFixture): Promise { fixture.detectChanges(); await fixture.whenStable(); TestBed.inject(ApplicationRef).tick(); fixture.detectChanges(); await fixture.whenStable(); } function trigger(fixture: ComponentFixture): HTMLButtonElement { return fixture.nativeElement.querySelector('.command-palette-trigger'); } function dialog(fixture: ComponentFixture): HTMLElement | null { return fixture.nativeElement.querySelector('[role="dialog"]'); } async function openViaTrigger(fixture: ComponentFixture): Promise { trigger(fixture).click(); await flush(fixture); } async function submitQuery( fixture: ComponentFixture, value: string, ): Promise { const input = fixture.nativeElement.querySelector('input'); expect(input).toBeTruthy(); input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); fixture.nativeElement .querySelector('form') .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); await flush(fixture); } function outputText(fixture: ComponentFixture): string { return fixture.nativeElement.querySelector('.command-palette-output')?.textContent ?? ''; } function focusable(container: HTMLElement): HTMLElement[] { return Array.from( container.querySelectorAll( 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', ), ).filter((element) => element.tabIndex >= 0); } it.each(APP_LOCALES)( 'renders the trigger and not the dialog on the server (%s)', async (locale) => { const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]); expect(trigger(fixture)).toBeTruthy(); expect(dialog(fixture)).toBeNull(); }, ); it.each(APP_LOCALES)('opens from the trigger and focuses the input (%s)', async (locale) => { const fixture = await createFixture(locale); await openViaTrigger(fixture); const input = fixture.nativeElement.querySelector('input'); expect(dialog(fixture)).toBeTruthy(); expect(document.activeElement).toBe(input); }); it.each(APP_LOCALES)( 'opens from Ctrl+K and Meta+K and prevents the default (%s)', async (locale) => { const fixture = await createFixture(locale); await flush(fixture); for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) { const openDialog = dialog(fixture); if (openDialog) { openDialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await flush(fixture); } const event = new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true, ...modifier, }); const prevent = vi.spyOn(event, 'preventDefault'); document.dispatchEvent(event); await flush(fixture); expect(prevent).toHaveBeenCalled(); expect(dialog(fixture)).toBeTruthy(); } }, ); it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => { const fixture = await createFixture(locale); await openViaTrigger(fixture); const openDialog = dialog(fixture); expect(openDialog).toBeTruthy(); openDialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await flush(fixture); expect(dialog(fixture)).toBeNull(); expect(document.activeElement).toBe(trigger(fixture)); }); it.each(APP_LOCALES)('closes from the close button and restores focus (%s)', async (locale) => { const fixture = await createFixture(locale); await openViaTrigger(fixture); const closeButton = fixture.nativeElement.querySelector('.command-palette-close'); closeButton.click(); await flush(fixture); expect(dialog(fixture)).toBeNull(); expect(document.activeElement).toBe(trigger(fixture)); }); it.each(APP_LOCALES)('wraps Tab and Shift+Tab inside the dialog (%s)', async (locale) => { const fixture = await createFixture(locale); await openViaTrigger(fixture); const openDialog = dialog(fixture); expect(openDialog).toBeTruthy(); const items = focusable(openDialog as HTMLElement); const first = items[0]; const last = items[items.length - 1]; last.focus(); openDialog?.dispatchEvent( new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }), ); expect(document.activeElement).toBe(first); first.focus(); openDialog?.dispatchEvent( new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }), ); expect(document.activeElement).toBe(last); }); it.each(APP_LOCALES)('writes localized help, playful replies and clear (%s)', async (locale) => { const fixture = await createFixture(locale); await openViaTrigger(fixture); const copy = SIGNATURE_COPY[locale].palette; await submitQuery(fixture, 'help'); const helpText = outputText(fixture); expect(helpText).toContain(copy.helpIntro); for (const command of COMMANDS) { expect(helpText).toContain(command.input); expect(helpText).toContain(copy.commandDescriptions[command.id]); } expect(COMMAND_IDS.every((id) => helpText.includes(copy.commandDescriptions[id]))).toBe(true); await submitQuery(fixture, 'brew'); expect(outputText(fixture)).toContain(copy.responses.brew); await submitQuery(fixture, 'ignite'); expect(outputText(fixture)).toContain(copy.responses.ignite); await submitQuery(fixture, 'rev'); expect(outputText(fixture)).toContain(copy.responses.rev); await submitQuery(fixture, 'clear'); expect(outputText(fixture).trim()).toBe(copy.clearedMessage); }); it.each(APP_LOCALES)( 'navigates projects, services ai and contact through NavigationService (%s)', async (locale) => { const fixture = await createFixture(locale); const router = TestBed.inject(Router); const navigation = TestBed.inject(NavigationService); const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); await openViaTrigger(fixture); await submitQuery(fixture, 'projects'); expect(navigate).toHaveBeenCalledWith(navigation.link('projects')); await openViaTrigger(fixture); await submitQuery(fixture, 'services ai'); expect(navigate).toHaveBeenCalledWith(navigation.link('servicesAi')); await openViaTrigger(fixture); await submitQuery(fixture, 'contact'); expect(navigate).toHaveBeenCalledWith(navigation.link('contact')); }, ); it.each(APP_LOCALES)('opens the CV in a new tab and never navigates (%s)', async (locale) => { const fixture = await createFixture(locale); const router = TestBed.inject(Router); const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); const view = TestBed.inject(DOCUMENT).defaultView; const open = vi.spyOn(view as Window, 'open').mockReturnValue(null); await openViaTrigger(fixture); await submitQuery(fixture, 'cv'); expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer'); expect(navigate).not.toHaveBeenCalled(); expect(outputText(fixture)).toContain(SIGNATURE_COPY[locale].palette.cvOpened); }); it.each(APP_LOCALES)( 'reports unknown input without navigating or opening a window (%s)', async (locale) => { const fixture = await createFixture(locale); const router = TestBed.inject(Router); const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); const view = TestBed.inject(DOCUMENT).defaultView; const open = vi.spyOn(view as Window, 'open').mockReturnValue(null); const raw = 'rm -rf /'; await openViaTrigger(fixture); await submitQuery(fixture, raw); expect(outputText(fixture)).toContain( SIGNATURE_COPY[locale].palette.unknownCommand.replace('{command}', raw), ); expect(navigate).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled(); }, ); it('removes the document keydown listener on destroy', async () => { const add = vi.spyOn(document, 'addEventListener'); const fixture = await createFixture(); await flush(fixture); const added = add.mock.calls.find((call) => call[0] === 'keydown'); expect(added).toBeTruthy(); const remove = vi.spyOn(document, 'removeEventListener'); fixture.destroy(); expect(remove).toHaveBeenCalledWith('keydown', added?.[1]); }); it('registers no document listener on the server', async () => { const add = vi.spyOn(document, 'addEventListener'); await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]); expect(add.mock.calls.some((call) => call[0] === 'keydown')).toBe(false); }); });