Files
Portfolio/src/app/app.spec.ts
Antonio Ledebuhr 55d012cc10 Open the systems-map gist dialog and turn the dock into a default-open TTY.
Plain node activation no longer navigates, case cards stay quiet evidence rows, and the terminal keeps one top-to-bottom scrollport.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 00:41:20 +02:00

169 lines
6.1 KiB
TypeScript

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<void> {
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<App>): Promise<void> {
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');
});
});