Ship the Signature UX as standalone, SSR-safe pieces: bilingual copy, a dual SVG/list systems map, a Map-backed command palette in the shell, opt-in reveal and metric primitives, and a leak-free decorative canvas. Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
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<ComponentFixture<CommandPalette>> {
|
|
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<CommandPalette>): Promise<void> {
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
TestBed.inject(ApplicationRef).tick();
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
}
|
|
|
|
function trigger(fixture: ComponentFixture<CommandPalette>): HTMLButtonElement {
|
|
return fixture.nativeElement.querySelector('.command-palette-trigger');
|
|
}
|
|
|
|
function dialog(fixture: ComponentFixture<CommandPalette>): HTMLElement | null {
|
|
return fixture.nativeElement.querySelector('[role="dialog"]');
|
|
}
|
|
|
|
async function openViaTrigger(fixture: ComponentFixture<CommandPalette>): Promise<void> {
|
|
trigger(fixture).click();
|
|
await flush(fixture);
|
|
}
|
|
|
|
async function submitQuery(
|
|
fixture: ComponentFixture<CommandPalette>,
|
|
value: string,
|
|
): Promise<void> {
|
|
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<CommandPalette>): string {
|
|
return fixture.nativeElement.querySelector('.command-palette-output')?.textContent ?? '';
|
|
}
|
|
|
|
function focusable(container: HTMLElement): HTMLElement[] {
|
|
return Array.from(
|
|
container.querySelectorAll<HTMLElement>(
|
|
'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);
|
|
});
|
|
});
|