import { readFileSync } from 'node:fs'; import { join } from 'node:path'; 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 { TerminalDock } from './terminal-dock'; import { TerminalDockService } from './terminal-dock.service'; describe('TerminalDock', () => { afterEach(() => { document.body.style.overflow = ''; vi.restoreAllMocks(); }); async function createFixture( locale: AppLocale = 'de', extraProviders: { provide: unknown; useValue: unknown }[] = [], ): Promise> { TestBed.resetTestingModule(); await TestBed.configureTestingModule({ imports: [TerminalDock], providers: [provideRouter([]), ...extraProviders], }).compileComponents(); TestBed.inject(LocaleService).setLocale(locale); const fixture = TestBed.createComponent(TerminalDock); 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('.terminal-dock-trigger'); } function panel(fixture: ComponentFixture): HTMLElement | null { return fixture.nativeElement.querySelector('.terminal-dock-panel'); } function input(fixture: ComponentFixture): HTMLInputElement | null { return fixture.nativeElement.querySelector('input'); } function session(fixture: ComponentFixture): HTMLElement | null { return fixture.nativeElement.querySelector('.terminal-dock-session'); } function collapseControl( fixture: ComponentFixture, locale: AppLocale, ): HTMLButtonElement { const label = SIGNATURE_COPY[locale].terminal.collapseLabel; return fixture.nativeElement.querySelector(`[aria-label="${label}"]`); } async function collapseViaControl( fixture: ComponentFixture, locale: AppLocale, ): Promise { collapseControl(fixture, locale).click(); await flush(fixture); } async function openViaTrigger(fixture: ComponentFixture): Promise { trigger(fixture).click(); await flush(fixture); } async function submitQuery( fixture: ComponentFixture, value: string, ): Promise { const field = input(fixture); expect(field).toBeTruthy(); field!.value = value; field!.dispatchEvent(new Event('input', { bubbles: true })); fixture.nativeElement .querySelector('form') ?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); await flush(fixture); } function logText(fixture: ComponentFixture): string { return session(fixture)?.textContent ?? ''; } it.each(APP_LOCALES)( 'renders the expanded panel on the server without stealing focus (%s)', async (locale) => { const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]); expect(trigger(fixture)).toBeTruthy(); expect(trigger(fixture).hasAttribute('hidden')).toBe(true); expect(panel(fixture)).toBeTruthy(); expect(session(fixture)).toBeTruthy(); expect(session(fixture)?.contains(input(fixture))).toBe(true); expect(document.activeElement).not.toBe(input(fixture)); }, ); it.each(APP_LOCALES)( 'defaults to expanded and does not steal focus on load (%s)', async (locale) => { const fixture = await createFixture(locale); await flush(fixture); const dock = TestBed.inject(TerminalDockService); const copy = SIGNATURE_COPY[locale].terminal; expect(dock.state()).toBe('expanded'); expect(panel(fixture)).toBeTruthy(); expect(trigger(fixture).hasAttribute('hidden')).toBe(true); expect(document.activeElement).not.toBe(input(fixture)); expect(fixture.nativeElement.querySelector('[role="dialog"]')).toBeNull(); expect(fixture.nativeElement.querySelector('.command-palette-scrim')).toBeNull(); const size = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement; const collapse = collapseControl(fixture, locale); expect(size.getAttribute('aria-label')).toBe(copy.maximizeLabel); expect(collapse.getAttribute('aria-label')).toBe(copy.collapseLabel); expect(size.getAttribute('title')).toBe(copy.maximizeTooltip); expect(collapse.getAttribute('title')).toBe(copy.collapseTooltip); expect(size.getAttribute('aria-label')).not.toBe(collapse.getAttribute('aria-label')); }, ); it.each(APP_LOCALES)( 'hides the trigger while open and shows it again after collapse (%s)', async (locale) => { const fixture = await createFixture(locale); const dock = TestBed.inject(TerminalDockService); expect(trigger(fixture).hasAttribute('hidden')).toBe(true); expect(panel(fixture)).toBeTruthy(); await collapseViaControl(fixture, locale); expect(panel(fixture)).toBeNull(); expect(dock.state()).toBe('collapsed'); expect(trigger(fixture).hasAttribute('hidden')).toBe(false); expect(document.activeElement).toBe(trigger(fixture)); await openViaTrigger(fixture); expect(panel(fixture)).toBeTruthy(); expect(dock.state()).toBe('expanded'); expect(trigger(fixture).hasAttribute('hidden')).toBe(true); expect(document.activeElement).toBe(input(fixture)); }, ); it.each(APP_LOCALES)('moves through collapsed, expanded and maximized (%s)', async (locale) => { const fixture = await createFixture(locale); const dock = TestBed.inject(TerminalDockService); expect(dock.state()).toBe('expanded'); fixture.nativeElement.querySelector('[aria-pressed]')?.click(); await flush(fixture); expect(dock.state()).toBe('maximized'); expect( fixture.nativeElement.querySelector('[aria-pressed]')?.getAttribute('aria-pressed'), ).toBe('true'); fixture.nativeElement.querySelector('[aria-pressed]')?.click(); await flush(fixture); expect(dock.state()).toBe('expanded'); }); it.each(APP_LOCALES)('uses distinct maximize and restore names (%s)', async (locale) => { const fixture = await createFixture(locale); const copy = SIGNATURE_COPY[locale].terminal; const button = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement; expect(button.getAttribute('aria-label')).toBe(copy.maximizeLabel); expect(button.getAttribute('title')).toBe(copy.maximizeTooltip); button.click(); await flush(fixture); expect(button.getAttribute('aria-label')).toBe(copy.restoreLabel); expect(button.getAttribute('title')).toBe(copy.restoreTooltip); expect(button.getAttribute('aria-pressed')).toBe('true'); }); it.each(APP_LOCALES)('toggles from Ctrl+K and Meta+K (%s)', async (locale) => { const fixture = await createFixture(locale); await flush(fixture); expect(panel(fixture)).toBeTruthy(); for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) { 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(panel(fixture)).toBeTruthy(); }); it('does not toggle or preventDefault on Ctrl/Cmd+Shift+K', async () => { const fixture = await createFixture(); await flush(fixture); expect(panel(fixture)).toBeTruthy(); for (const modifier of [ { ctrlKey: true, shiftKey: true }, { metaKey: true, shiftKey: true }, ] as const) { 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).not.toHaveBeenCalled(); expect(panel(fixture)).toBeTruthy(); } const closeEvent = new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true, ctrlKey: true, }); const preventClose = vi.spyOn(closeEvent, 'preventDefault'); document.dispatchEvent(closeEvent); await flush(fixture); expect(preventClose).toHaveBeenCalled(); expect(panel(fixture)).toBeNull(); const shiftWhileClosed = new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true, ctrlKey: true, shiftKey: true, }); const preventShift = vi.spyOn(shiftWhileClosed, 'preventDefault'); document.dispatchEvent(shiftWhileClosed); await flush(fixture); expect(preventShift).not.toHaveBeenCalled(); expect(panel(fixture)).toBeNull(); const metaOpen = new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true, metaKey: true, }); const preventMeta = vi.spyOn(metaOpen, 'preventDefault'); document.dispatchEvent(metaOpen); await flush(fixture); expect(preventMeta).toHaveBeenCalled(); expect(panel(fixture)).toBeTruthy(); }); it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => { const fixture = await createFixture(locale); input(fixture)?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await flush(fixture); expect(panel(fixture)).toBeNull(); expect(document.activeElement).toBe(trigger(fixture)); }); it.each(APP_LOCALES)( 'closes on Escape from a panel control and returns focus to the trigger (%s)', async (locale) => { const fixture = await createFixture(locale); const maximize = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement; maximize.focus(); expect(document.activeElement).toBe(maximize); maximize.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await flush(fixture); expect(panel(fixture)).toBeNull(); expect(document.activeElement).toBe(trigger(fixture)); }, ); it.each(APP_LOCALES)('does not lock scroll or mark the page inert (%s)', async (locale) => { document.body.style.overflow = 'auto'; const fixture = await createFixture(locale); await flush(fixture); expect(document.body.style.overflow).toBe('auto'); expect(document.querySelector('.site')?.hasAttribute('inert')).toBeFalsy(); expect(fixture.nativeElement.querySelector('[aria-modal]')).toBeNull(); document.body.style.overflow = ''; }); it.each(APP_LOCALES)( 'echoes the prompt, keeps the log and walks command history (%s)', async (locale) => { const fixture = await createFixture(locale); const copy = SIGNATURE_COPY[locale].terminal; await submitQuery(fixture, 'help'); const helpText = logText(fixture); expect(helpText).toContain(`${copy.prompt} help`); expect(helpText).toContain(copy.helpIntro); expect(helpText).toContain(copy.helpGrammar); await submitQuery(fixture, 'brew'); expect(logText(fixture)).toContain(copy.responses.brew); expect(logText(fixture)).toContain(`${copy.prompt} help`); await submitQuery(fixture, 'rev'); expect(logText(fixture)).toContain(copy.responses.rev); await submitQuery(fixture, 'history'); expect(logText(fixture)).toContain(copy.historyIntro); expect(logText(fixture)).toContain('help'); const field = input(fixture); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await flush(fixture); expect(field?.value).toBe('history'); await submitQuery(fixture, 'clear'); expect(logText(fixture)).toContain(copy.clearedMessage); }, ); it.each(APP_LOCALES)( 'scrolls the session to keep the prompt visible after output (%s)', async (locale) => { const fixture = await createFixture(locale); const sessionEl = session(fixture) as HTMLElement; const scrollTo = vi.fn(); Object.defineProperty(sessionEl, 'scrollHeight', { configurable: true, get: () => 480 }); Object.defineProperty(sessionEl, 'scrollTo', { configurable: true, value: scrollTo }); await submitQuery(fixture, 'help'); expect(scrollTo).toHaveBeenCalled(); expect(scrollTo.mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ top: 480, }), ); }, ); it.each(APP_LOCALES)('walks command history like a conventional shell (%s)', async (locale) => { const fixture = await createFixture(locale); await submitQuery(fixture, 'help'); await submitQuery(fixture, 'brew'); await submitQuery(fixture, 'rev'); const field = input(fixture); expect(field?.value).toBe(''); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flush(fixture); expect(field?.value).toBe(''); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await flush(fixture); expect(field?.value).toBe('rev'); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await flush(fixture); expect(field?.value).toBe('brew'); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await flush(fixture); expect(field?.value).toBe('help'); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await flush(fixture); expect(field?.value).toBe('help'); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flush(fixture); expect(field?.value).toBe('brew'); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flush(fixture); expect(field?.value).toBe('rev'); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flush(fixture); expect(field?.value).toBe(''); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flush(fixture); expect(field?.value).toBe(''); }); it.each(APP_LOCALES)( 'restores an in-progress draft after walking off the newest history (%s)', async (locale) => { const fixture = await createFixture(locale); await submitQuery(fixture, 'help'); const field = input(fixture); field!.value = 'nav'; field!.dispatchEvent(new Event('input', { bubbles: true })); await flush(fixture); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await flush(fixture); expect(field?.value).toBe('help'); field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flush(fixture); expect(field?.value).toBe('nav'); }, ); it.each(APP_LOCALES)( 'navigates a known target, jumps to a fragment, and opens the CV asset (%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); const view = TestBed.inject(DOCUMENT).defaultView; const open = vi.spyOn(view as Window, 'open').mockReturnValue(null); const copy = SIGNATURE_COPY[locale].terminal; await submitQuery(fixture, 'navigate pitch'); expect(navigate).toHaveBeenCalledWith(navigation.link('pitch'), undefined); expect(logText(fixture)).toContain(copy.navigating.replace('{target}', 'pitch')); await submitQuery(fixture, 'navigate services software'); expect(navigate).toHaveBeenCalledWith(navigation.link('services'), { fragment: 'software-data', }); expect(logText(fixture)).toContain(copy.jumping.replace('{target}', 'services software')); await submitQuery(fixture, 'navigate cv'); expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer'); }, ); 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); }); 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(); TestBed.resetTestingModule(); expect(remove).toHaveBeenCalledWith('keydown', added?.[1]); }); }); describe('terminal dock stylesheet', () => { const css = readFileSync( join(process.cwd(), 'src/app/shared/terminal/terminal-dock.scss'), 'utf8', ); it('does not fade a text-bearing terminal container during entry', () => { expect(css).toMatch(/\.terminal-dock-panel[\s\S]*?animation:\s*terminal-dock-in/); const keyframesStart = css.indexOf('@keyframes terminal-dock-in'); expect(keyframesStart).toBeGreaterThan(-1); const reducedMotion = css.indexOf('@media (prefers-reduced-motion: reduce)', keyframesStart); expect(reducedMotion).toBeGreaterThan(keyframesStart); const keyframes = css.slice(keyframesStart, reducedMotion); expect(keyframes).toMatch(/transform:\s*translateY/); expect(keyframes).not.toMatch(/opacity\s*:/); const panelStart = css.indexOf('.terminal-dock-panel {'); const panelBlock = css.slice(panelStart, css.indexOf('}', panelStart) + 1); expect(panelBlock).toContain('animation: terminal-dock-in'); expect(panelBlock).not.toMatch(/opacity\s*:/); const reduceBlock = css.slice(css.indexOf('prefers-reduced-motion')); expect(reduceBlock).toMatch(/\.terminal-dock-panel\s*\{[^}]*animation:\s*none/); expect(css).toMatch( /\.terminal-dock-form input::placeholder[\s\S]*?color:\s*var\(--color-text-subtle\)[\s\S]*?opacity:\s*1/, ); }); });