import { ApplicationRef, PLATFORM_ID } from '@angular/core'; import { TestBed, type ComponentFixture } from '@angular/core/testing'; import { provideRouter, Router, TitleStrategy } from '@angular/router'; import { App, WIDE_NAV_QUERY } from './app'; import { routes } from './app.routes'; import { SITE_CONTENT } from './core/content/content.token'; import { SITE_CONTENT_DATA } from './core/content/site-content'; import { SeoTitleStrategy } from './core/seo/seo-title.strategy'; function mockViewport(wide: boolean): void { Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: (query: string): MediaQueryList => ({ matches: wide && query === WIDE_NAV_QUERY, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), }) as MediaQueryList, }); } async function configureApp( extraProviders: { provide: unknown; useValue: unknown }[] = [], ): Promise { await TestBed.configureTestingModule({ imports: [App], providers: [ provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }, { provide: TitleStrategy, useClass: SeoTitleStrategy }, ...extraProviders, ], }).compileComponents(); } async function flush(fixture: ComponentFixture): Promise { fixture.detectChanges(); await fixture.whenStable(); TestBed.inject(ApplicationRef).tick(); fixture.detectChanges(); await fixture.whenStable(); } function toggle(root: HTMLElement): HTMLButtonElement { return root.querySelector('.nav-toggle') as HTMLButtonElement; } describe('App', () => { beforeEach(async () => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); mockViewport(true); await configureApp(); }); afterEach(() => { vi.restoreAllMocks(); Reflect.deleteProperty(window, 'matchMedia'); }); it('should create the shell', async () => { const fixture = TestBed.createComponent(App); await fixture.whenStable(); expect(fixture.componentInstance).toBeTruthy(); }); it('should render an accessible application shell', async () => { const fixture = TestBed.createComponent(App); await fixture.whenStable(); const compiled = fixture.nativeElement as HTMLElement; const focusable = compiled.querySelectorAll( 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', ); const firstFocusable = focusable.item(0); expect(firstFocusable).toBeTruthy(); expect(firstFocusable.getAttribute('href')).toBe('#main-content'); expect(firstFocusable.classList.contains('skip-link')).toBe(true); expect(compiled.querySelector('main#main-content')).toBeTruthy(); expect(compiled.querySelector('nav[aria-label]')).toBeTruthy(); expect(compiled.querySelector('a.language-switch[hreflang]')).toBeTruthy(); expect(compiled.querySelector('footer')).toBeTruthy(); }); it('renders the terminal dock outside .site and keeps the header free of a CV link', async () => { const fixture = TestBed.createComponent(App); await fixture.whenStable(); const compiled = fixture.nativeElement as HTMLElement; const site = compiled.querySelector('.site'); const dock = compiled.querySelector('app-terminal-dock'); const trigger = compiled.querySelector('.terminal-dock-trigger'); expect(site).toBeTruthy(); expect(dock).toBeTruthy(); expect(site?.contains(dock)).toBe(false); expect(compiled.querySelector('header a[href="/cv/CV.pdf"]')).toBeNull(); expect(compiled.querySelector('header .command-palette-trigger')).toBeNull(); const panel = compiled.querySelector('.terminal-dock-panel'); expect(panel).toBeTruthy(); expect((trigger as HTMLButtonElement).hasAttribute('hidden')).toBe(true); expect(site?.contains(panel)).toBe(false); expect(site?.hasAttribute('inert')).toBe(false); }); it('keeps the server-rendered nav expanded and collapses after hydration on a narrow viewport', async () => { TestBed.resetTestingModule(); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); mockViewport(false); await configureApp(); const fixture = TestBed.createComponent(App); const shell = fixture.componentInstance as unknown as { navOpen: () => boolean }; expect(shell.navOpen()).toBe(true); await flush(fixture); const button = toggle(fixture.nativeElement); expect(button.getAttribute('aria-expanded')).toBe('false'); expect(button.getAttribute('aria-controls')).toBe('primary-nav'); expect(button.getAttribute('aria-label')).toBeTruthy(); expect(fixture.nativeElement.querySelector('.site')?.classList.contains('nav-collapsed')).toBe( true, ); }); it('does not collapse the nav on the server even when the viewport helper would be narrow', async () => { TestBed.resetTestingModule(); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); mockViewport(false); await configureApp([{ provide: PLATFORM_ID, useValue: 'server' }]); const fixture = TestBed.createComponent(App); await flush(fixture); expect(toggle(fixture.nativeElement).getAttribute('aria-expanded')).toBe('true'); expect(fixture.nativeElement.querySelector('.site')?.classList.contains('nav-collapsed')).toBe( false, ); }); it('closes the nav on navigation when the viewport is narrow', async () => { TestBed.resetTestingModule(); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); mockViewport(false); await configureApp(); const fixture = TestBed.createComponent(App); await flush(fixture); const button = toggle(fixture.nativeElement); expect(button.getAttribute('aria-expanded')).toBe('false'); button.click(); fixture.detectChanges(); expect(button.getAttribute('aria-expanded')).toBe('true'); await TestBed.inject(Router).navigateByUrl('/projekte'); await flush(fixture); expect(toggle(fixture.nativeElement).getAttribute('aria-expanded')).toBe('false'); }); });