From f7e9a0650bde11e48937e3d5cbdef3bcf484770b Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Mon, 27 Apr 2026 18:11:36 +0200 Subject: [PATCH] improve: tests --- .claude/settings.local.json | 7 + angular.json | 1 + src/app/app.component.spec.ts | 53 ++- src/app/footer/footer.component.spec.ts | 84 +++- src/app/home/home.component.spec.ts | 471 +++++++++++++++++++++- src/app/imprint/imprint.component.spec.ts | 167 +++++++- 6 files changed, 762 insertions(+), 21 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1ce6f63 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npx tsc *)" + ] + } +} diff --git a/angular.json b/angular.json index 8efceeb..697f552 100644 --- a/angular.json +++ b/angular.json @@ -102,6 +102,7 @@ "test": { "builder": "@angular-devkit/build-angular:karma", "options": { + "watch": false, "polyfills": ["zone.js", "zone.js/testing"], "tsConfig": "tsconfig.spec.json", "inlineStyleLanguage": "scss", diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 92090bc..5e036b7 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,31 +1,50 @@ import type { ComponentFixture } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { AppComponent } from './app.component'; describe('AppComponent', (): void => { + let fixture: ComponentFixture; + let component: AppComponent; + beforeEach(async (): Promise => { await TestBed.configureTestingModule({ imports: [AppComponent], + providers: [provideRouter([])], }).compileComponents(); - }); - it('should create the app', (): void => { - const fixture: ComponentFixture = TestBed.createComponent(AppComponent); - const app: AppComponent = fixture.componentInstance; - expect(app).toBeTruthy(); - }); - - it(`should have the 'Angular-Portfolio-Seite' title`, (): void => { - const fixture: ComponentFixture = TestBed.createComponent(AppComponent); - const app: AppComponent = fixture.componentInstance; - expect(app.title).toEqual('Angular-Portfolio-Seite'); - }); - - it('should render title', (): void => { - const fixture: ComponentFixture = TestBed.createComponent(AppComponent); + fixture = TestBed.createComponent(AppComponent); + component = fixture.componentInstance; fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, Angular-Portfolio-Seite'); + }); + + it('should create', (): void => { + expect(component).toBeTruthy(); + }); + + it('should have title "Lechner Julian"', (): void => { + expect(component.title).toBe('Lechner Julian'); + }); + + it('should render a router-outlet element', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('router-outlet')).not.toBeNull(); + }); + + it('should not render any additional root elements besides router-outlet', (): void => { + const el = fixture.nativeElement as HTMLElement; + const children = Array.from(el.children); + expect(children.length).toBe(1); + expect(children[0].tagName.toLowerCase()).toBe('router-outlet'); + }); + + describe('Performance', (): void => { + it('should create and run initial change detection within 100 ms', (): void => { + const start = performance.now(); + const f = TestBed.createComponent(AppComponent); + f.detectChanges(); + expect(performance.now() - start).toBeLessThan(100); + }); }); }); diff --git a/src/app/footer/footer.component.spec.ts b/src/app/footer/footer.component.spec.ts index 073696d..e12cdb6 100644 --- a/src/app/footer/footer.component.spec.ts +++ b/src/app/footer/footer.component.spec.ts @@ -1,23 +1,105 @@ import type { ComponentFixture } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { FooterComponent } from './footer.component'; +import { global } from '../../global'; describe('FooterComponent', (): void => { - let component: FooterComponent; let fixture: ComponentFixture; + let component: FooterComponent; + + interface ComponentAccess { + currentYear: number; + global: typeof global; + } + + let comp: ComponentAccess; beforeEach(async (): Promise => { await TestBed.configureTestingModule({ imports: [FooterComponent], + providers: [provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(FooterComponent); component = fixture.componentInstance; + comp = component as unknown as ComponentAccess; fixture.detectChanges(); }); it('should create', (): void => { expect(component).toBeTruthy(); }); + + describe('currentYear', (): void => { + it('should equal the current calendar year', (): void => { + expect(comp.currentYear).toBe(new Date().getFullYear()); + }); + + it('should be a four-digit number', (): void => { + expect(comp.currentYear).toBeGreaterThanOrEqual(2024); + expect(comp.currentYear).toBeLessThan(2100); + }); + }); + + describe('global data', (): void => { + it('should reference the shared global object', (): void => { + expect(comp.global).toBe(global); + }); + + it('should expose a non-empty firstname', (): void => { + expect(comp.global.firstname.length).toBeGreaterThan(0); + }); + + it('should expose a non-empty lastname', (): void => { + expect(comp.global.lastname.length).toBeGreaterThan(0); + }); + }); + + describe('Template', (): void => { + it('should render the current year in the copyright line', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain(String(new Date().getFullYear())); + }); + + it('should render the author firstname', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain(global.firstname); + }); + + it('should render the author lastname', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain(global.lastname); + }); + + it('should render an imprint link with text "Imprint"', (): void => { + const el = fixture.nativeElement as HTMLElement; + const links = Array.from(el.querySelectorAll('a')); + const imprintLink = links.find((a): boolean => a.textContent?.trim() === 'Imprint'); + expect(imprintLink).toBeTruthy(); + }); + + it('should contain a footer element', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('footer')).not.toBeNull(); + }); + }); + + describe('Performance', (): void => { + it('should create and detect changes within 50 ms', (): void => { + const start = performance.now(); + const f = TestBed.createComponent(FooterComponent); + f.detectChanges(); + expect(performance.now() - start).toBeLessThan(50); + }); + + it('should run 20 consecutive change detection cycles within 100 ms', (): void => { + const start = performance.now(); + for (let i = 0; i < 20; i++) { + fixture.detectChanges(); + } + expect(performance.now() - start).toBeLessThan(100); + }); + }); }); diff --git a/src/app/home/home.component.spec.ts b/src/app/home/home.component.spec.ts index 380f0fc..106d0d9 100644 --- a/src/app/home/home.component.spec.ts +++ b/src/app/home/home.component.spec.ts @@ -1,23 +1,490 @@ import type { ComponentFixture } from '@angular/core/testing'; -import { TestBed } from '@angular/core/testing'; +import { TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { IMAGE_LOADER } from '@angular/common'; +import type { ImageLoaderConfig } from '@angular/common'; +import { NgZone } from '@angular/core'; +import type { Subscription } from 'rxjs'; import { HomeComponent } from './home.component'; +import { global } from '../../global'; +import type { BioTextEntry, PortraitHighlight } from '../../global'; describe('HomeComponent', (): void => { - let component: HomeComponent; let fixture: ComponentFixture; + let component: HomeComponent; + + interface ComponentAccess { + age: number; + currentIndex: number; + isLongBioShown: boolean; + isLongBioMounted: boolean; + isPortraitSwitching: boolean; + currentPortraitHighlightIndex: number; + sub: Subscription; + bioHideTimeoutId: number | null; + scrollAnimationFrameId: number | null; + projectEntryAnimationFrameId: number | null; + aboutSectionAnimationFrameId: number | null; + scheduleNameGradientUpdate(): void; + scheduleProjectEntryRevealUpdate(): void; + scheduleAboutSectionScrollAnimationUpdate(): void; + toggleBio(): void; + showNextPortraitHighlight(): void; + scrollToAboutSection(): void; + scrollToProjectsSection(): void; + scrollToContactLinks(): void; + currentBioEntry: BioTextEntry; + currentPortraitHighlight: PortraitHighlight | null; + } + + let comp: ComponentAccess; beforeEach(async (): Promise => { await TestBed.configureTestingModule({ imports: [HomeComponent], + providers: [ + provideRouter([]), + { + provide: IMAGE_LOADER, + useValue: (config: ImageLoaderConfig): string => config.src, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(HomeComponent); component = fixture.componentInstance; + comp = component as unknown as ComponentAccess; fixture.detectChanges(); }); + afterEach((): void => { + // Explicitly destroy to stop the interval(1000) subscription started in ngOnInit. + // Without this, the real setInterval keeps the browser's event loop alive after + // the test suite completes, causing Karma to disconnect with a 30 s timeout. + fixture.destroy(); + }); + it('should create', (): void => { expect(component).toBeTruthy(); }); + + // ── Age ──────────────────────────────────────────────────────────────────── + + describe('age', (): void => { + it('should be a positive number', (): void => { + expect(comp.age).toBeGreaterThan(0); + }); + + it('should match the expected age derived from the birthdate', (): void => { + const birth = new Date(global.birthdate); + const now = new Date(); + let expected = now.getFullYear() - birth.getFullYear(); + const hasPassed = + now.getMonth() > birth.getMonth() || + (now.getMonth() === birth.getMonth() && now.getDate() >= birth.getDate()); + if (!hasPassed) expected -= 1; + expect(comp.age).toBe(expected); + }); + }); + + // ── Bio cycling ──────────────────────────────────────────────────────────── + + describe('currentBioEntry getter', (): void => { + it('should return the first bioTextsList entry at index 0', (): void => { + comp.currentIndex = 0; + expect(comp.currentBioEntry).toEqual(global.bioTextsList[0]); + }); + + it('should return the correct entry when index changes', (): void => { + comp.currentIndex = 3; + expect(comp.currentBioEntry).toEqual(global.bioTextsList[3]); + }); + + it('should return the fallback entry when index is out of range', (): void => { + comp.currentIndex = 99999; + expect(comp.currentBioEntry.label).toBeDefined(); + expect(comp.currentBioEntry.value).toBeDefined(); + }); + }); + + describe('interval-driven bio cycling (subscription)', (): void => { + it('should start with currentIndex 0', (): void => { + expect(comp.currentIndex).toBe(0); + }); + + it('should be backed by an active RxJS Subscription', (): void => { + expect(comp.sub).toBeDefined(); + expect(comp.sub.closed).toBeFalse(); + }); + + it('should correctly wrap from the last entry back to 0 using modulo', (): void => { + const last = global.bioTextsList.length - 1; + comp.currentIndex = last; + expect(comp.currentBioEntry).toEqual(global.bioTextsList[last]); + comp.currentIndex = (last + 1) % global.bioTextsList.length; + expect(comp.currentIndex).toBe(0); + expect(comp.currentBioEntry).toEqual(global.bioTextsList[0]); + }); + }); + + // ── Portrait highlight ───────────────────────────────────────────────────── + + describe('currentPortraitHighlight getter', (): void => { + it('should return the highlight at the current index', (): void => { + comp.currentPortraitHighlightIndex = 0; + expect(comp.currentPortraitHighlight).toEqual(global.portraitHighlights[0]); + }); + + it('should return null when index is out of range', (): void => { + comp.currentPortraitHighlightIndex = 99999; + expect(comp.currentPortraitHighlight).toBeNull(); + }); + }); + + describe('showNextPortraitHighlight', (): void => { + // Timer tests run outside Angular zone to prevent NgOptimizedImage from + // throwing NG02953 when height/width inputs change during re-render. + + it('should set isPortraitSwitching to true immediately', (): void => { + comp.isPortraitSwitching = false; + comp.showNextPortraitHighlight(); + expect(comp.isPortraitSwitching).toBeTrue(); + }); + + it('should not advance index when already switching', (): void => { + comp.isPortraitSwitching = true; + comp.showNextPortraitHighlight(); + expect(comp.currentPortraitHighlightIndex).toBe(0); + }); + + it('should not call setTimeout when only one portrait exists', (): void => { + const highlights = (comp as unknown as { portraitHighlights: PortraitHighlight[] }) + .portraitHighlights; + const saved = [...highlights]; // snapshot before mutation + highlights.splice(1, saved.length - 1); // reduce to a single entry + + const setSpy = spyOn(window, 'setTimeout'); + comp.showNextPortraitHighlight(); + expect(setSpy).not.toHaveBeenCalled(); + + highlights.splice(0, highlights.length, ...saved); // restore from snapshot + }); + + it('should advance the portrait index after 125 ms', fakeAsync((): void => { + const zone = TestBed.inject(NgZone); + zone.runOutsideAngular((): void => { comp.showNextPortraitHighlight(); }); + tick(125); + expect(comp.currentPortraitHighlightIndex).toBe(1); + tick(125); + discardPeriodicTasks(); + })); + + it('should reset isPortraitSwitching to false after 250 ms', fakeAsync((): void => { + const zone = TestBed.inject(NgZone); + zone.runOutsideAngular((): void => { comp.showNextPortraitHighlight(); }); + tick(250); + expect(comp.isPortraitSwitching).toBeFalse(); + discardPeriodicTasks(); + })); + + it('should wrap portrait index from the last position back to 0', fakeAsync((): void => { + comp.currentPortraitHighlightIndex = global.portraitHighlights.length - 1; + const zone = TestBed.inject(NgZone); + zone.runOutsideAngular((): void => { comp.showNextPortraitHighlight(); }); + tick(125); + expect(comp.currentPortraitHighlightIndex).toBe(0); + tick(125); + discardPeriodicTasks(); + })); + }); + + // ── Bio toggle ───────────────────────────────────────────────────────────── + + describe('toggleBio', (): void => { + it('should mount bio immediately on opening', fakeAsync((): void => { + comp.toggleBio(); + expect(comp.isLongBioMounted).toBeTrue(); + tick(100); + discardPeriodicTasks(); + })); + + it('should set isLongBioShown to true after the double-rAF delay', fakeAsync((): void => { + comp.toggleBio(); + tick(50); // allow both nested requestAnimationFrame calls to execute + fixture.detectChanges(); + expect(comp.isLongBioShown).toBeTrue(); + discardPeriodicTasks(); + })); + + it('should set isLongBioShown to false immediately on close', fakeAsync((): void => { + // Open first + comp.toggleBio(); + tick(50); + fixture.detectChanges(); + + // Close + comp.toggleBio(); + fixture.detectChanges(); + expect(comp.isLongBioShown).toBeFalse(); + tick(300); + discardPeriodicTasks(); + })); + + it('should keep isLongBioMounted true during the 300 ms close animation', fakeAsync((): void => { + comp.toggleBio(); + tick(50); + fixture.detectChanges(); + + comp.toggleBio(); + fixture.detectChanges(); + expect(comp.isLongBioMounted).toBeTrue(); + + tick(299); + expect(comp.isLongBioMounted).toBeTrue(); + + tick(1); + fixture.detectChanges(); + expect(comp.isLongBioMounted).toBeFalse(); + discardPeriodicTasks(); + })); + + it('should cancel pending close timeout when re-opened during animation', fakeAsync((): void => { + comp.toggleBio(); + tick(50); + fixture.detectChanges(); + + comp.toggleBio(); // start closing + tick(150); // halfway through 300 ms + + comp.toggleBio(); // re-open before unmount + tick(50); + fixture.detectChanges(); + + expect(comp.isLongBioMounted).toBeTrue(); + expect(comp.isLongBioShown).toBeTrue(); + + tick(500); + discardPeriodicTasks(); + })); + }); + + // ── Scroll helpers ───────────────────────────────────────────────────────── + + describe('scroll helpers', (): void => { + let scrollSpy: jasmine.Spy; + + beforeEach((): void => { + scrollSpy = spyOn(window, 'scrollTo'); + }); + + it('should call window.scrollTo when scrolling to the about section', (): void => { + const el = document.createElement('section'); + el.id = 'about'; + document.body.appendChild(el); + comp.scrollToAboutSection(); + expect(scrollSpy).toHaveBeenCalled(); + document.body.removeChild(el); + }); + + it('should call window.scrollTo when scrolling to the projects section', (): void => { + const el = document.createElement('section'); + el.id = 'projects'; + document.body.appendChild(el); + comp.scrollToProjectsSection(); + expect(scrollSpy).toHaveBeenCalled(); + document.body.removeChild(el); + }); + + it('should call window.scrollTo when scrolling to contact links', (): void => { + const el = document.createElement('div'); + el.id = 'contact-links'; + document.body.appendChild(el); + comp.scrollToContactLinks(); + expect(scrollSpy).toHaveBeenCalled(); + document.body.removeChild(el); + }); + + it('should not throw when the target element does not exist', (): void => { + expect((): void => comp.scrollToAboutSection()).not.toThrow(); + }); + + it('"About me" button click should trigger a scrollTo call', (): void => { + const aboutEl = document.createElement('section'); + aboutEl.id = 'about'; + document.body.appendChild(aboutEl); + + const el = fixture.nativeElement as HTMLElement; + const btn = Array.from(el.querySelectorAll('button')).find((b): boolean => + (b.textContent ?? '').trim() === 'About me', + ); + btn?.click(); + expect(scrollSpy).toHaveBeenCalled(); + document.body.removeChild(aboutEl); + }); + + it('"View projects" button click should trigger a scrollTo call', (): void => { + const projectsEl = document.createElement('section'); + projectsEl.id = 'projects'; + document.body.appendChild(projectsEl); + + const el = fixture.nativeElement as HTMLElement; + const btn = Array.from(el.querySelectorAll('button')).find((b): boolean => + (b.textContent ?? '').trim() === 'View projects', + ); + btn?.click(); + expect(scrollSpy).toHaveBeenCalled(); + document.body.removeChild(projectsEl); + }); + }); + + // ── Lifecycle cleanup ────────────────────────────────────────────────────── + + describe('ngOnDestroy', (): void => { + it('should unsubscribe the interval subscription on destroy', fakeAsync((): void => { + expect(comp.sub.closed).toBeFalse(); + fixture.destroy(); + expect(comp.sub.closed).toBeTrue(); + discardPeriodicTasks(); + })); + + it('should not throw when destroyed', fakeAsync((): void => { + expect((): void => fixture.destroy()).not.toThrow(); + discardPeriodicTasks(); + })); + }); + + // ── Template rendering ───────────────────────────────────────────────────── + + describe('Template', (): void => { + it('should render the hero section', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.hero-section')).not.toBeNull(); + }); + + it('should render the author firstname in the h1', (): void => { + const el = fixture.nativeElement as HTMLElement; + const h1 = el.querySelector('h1'); + expect(h1?.textContent).toContain(global.firstname); + }); + + it('should render the author lastname in the h1', (): void => { + const el = fixture.nativeElement as HTMLElement; + const h1 = el.querySelector('h1'); + expect(h1?.textContent).toContain(global.lastname); + }); + + it('should render the bio label from the current bioTextsList entry', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.meta-label')).not.toBeNull(); + }); + + it('should render a portrait image when a highlight is active', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.portrait-highlight')).not.toBeNull(); + }); + + it('should render the about section', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('#about')).not.toBeNull(); + }); + + it('should render the projects section', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('#projects')).not.toBeNull(); + }); + + it('should render one project entry per global.projects entry', (): void => { + const el = fixture.nativeElement as HTMLElement; + const entries = el.querySelectorAll('.project-entry'); + expect(entries.length).toBe(global.projects.length); + }); + + it('should render all project titles', (): void => { + const el = fixture.nativeElement as HTMLElement; + global.projects.forEach((project): void => { + expect(el.textContent).toContain(project.title); + }); + }); + + it('should render contact link anchors in the hero section', (): void => { + const el = fixture.nativeElement as HTMLElement; + const contactArea = el.querySelector('#contact-links'); + const links = contactArea?.querySelectorAll('a'); + expect(links?.length).toBeGreaterThan(0); + }); + }); + + // ── RAF debouncing (performance) ─────────────────────────────────────────── + + describe('Performance', (): void => { + it('should create and run initial change detection within 200 ms', (): void => { + const start = performance.now(); + const f = TestBed.createComponent(HomeComponent); + f.detectChanges(); + expect(performance.now() - start).toBeLessThan(200); + f.destroy(); + }); + + it('should run 10 consecutive change detection cycles within 100 ms', (): void => { + const start = performance.now(); + for (let i = 0; i < 10; i++) { + fixture.detectChanges(); + } + expect(performance.now() - start).toBeLessThan(100); + }); + + it('should deduplicate rapid schedule calls to a single requestAnimationFrame', fakeAsync((): void => { + // The RAF registered during beforeEach's ngOnInit lives outside this fakeAsync zone. + // Resetting the ID to null makes the schedule function treat the slot as free. + comp.scrollAnimationFrameId = null; + + let rafCallCount = 0; + spyOn(window, 'requestAnimationFrame').and.callFake((): number => { + rafCallCount++; + return rafCallCount; // returns non-null so subsequent calls deduplicate + }); + + comp.scheduleNameGradientUpdate(); + comp.scheduleNameGradientUpdate(); + comp.scheduleNameGradientUpdate(); + + expect(rafCallCount).toBe(1); + discardPeriodicTasks(); + })); + + it('should deduplicate project-entry reveal scheduling the same way', fakeAsync((): void => { + comp.projectEntryAnimationFrameId = null; + + let rafCallCount = 0; + spyOn(window, 'requestAnimationFrame').and.callFake((): number => { + rafCallCount++; + return rafCallCount; + }); + + comp.scheduleProjectEntryRevealUpdate(); + comp.scheduleProjectEntryRevealUpdate(); + comp.scheduleProjectEntryRevealUpdate(); + + expect(rafCallCount).toBe(1); + discardPeriodicTasks(); + })); + + it('should deduplicate about-section scroll scheduling the same way', fakeAsync((): void => { + comp.aboutSectionAnimationFrameId = null; + + let rafCallCount = 0; + spyOn(window, 'requestAnimationFrame').and.callFake((): number => { + rafCallCount++; + return rafCallCount; + }); + + comp.scheduleAboutSectionScrollAnimationUpdate(); + comp.scheduleAboutSectionScrollAnimationUpdate(); + comp.scheduleAboutSectionScrollAnimationUpdate(); + + expect(rafCallCount).toBe(1); + discardPeriodicTasks(); + })); + }); }); diff --git a/src/app/imprint/imprint.component.spec.ts b/src/app/imprint/imprint.component.spec.ts index 6fc601d..768148e 100644 --- a/src/app/imprint/imprint.component.spec.ts +++ b/src/app/imprint/imprint.component.spec.ts @@ -1,23 +1,188 @@ import type { ComponentFixture } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import type { SafeUrl } from '@angular/platform-browser'; import { ImprintComponent } from './imprint.component'; +import { global } from '../../global'; describe('ImprintComponent', (): void => { - let component: ImprintComponent; let fixture: ComponentFixture; + let component: ImprintComponent; + + interface ComponentAccess { + contactSafeMail: SafeUrl; + abuseSafeMail: SafeUrl; + displayStreet: string; + displayCity: string; + normalizeText(value: string): string; + } + + let comp: ComponentAccess; beforeEach(async (): Promise => { await TestBed.configureTestingModule({ imports: [ImprintComponent], + providers: [provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(ImprintComponent); component = fixture.componentInstance; + comp = component as unknown as ComponentAccess; fixture.detectChanges(); }); it('should create', (): void => { expect(component).toBeTruthy(); }); + + describe('SafeUrl initialization (ngOnInit)', (): void => { + it('should initialize contactSafeMail', (): void => { + expect(comp.contactSafeMail).toBeDefined(); + }); + + it('should initialize abuseSafeMail', (): void => { + expect(comp.abuseSafeMail).toBeDefined(); + }); + + it('should embed the contact email in contactSafeMail', (): void => { + expect(String(comp.contactSafeMail)).toContain(global.contactMail); + }); + + it('should embed the abuse email in abuseSafeMail', (): void => { + expect(String(comp.abuseSafeMail)).toContain(global.abuseMail); + }); + + it('should use a mailto: scheme for contactSafeMail', (): void => { + expect(String(comp.contactSafeMail)).toContain('mailto:'); + }); + + it('should use a mailto: scheme for abuseSafeMail', (): void => { + expect(String(comp.abuseSafeMail)).toContain('mailto:'); + }); + }); + + describe('normalizeText', (): void => { + it('should return a string', (): void => { + expect(typeof comp.normalizeText('test')).toBe('string'); + }); + + it('should leave plain ASCII text unchanged', (): void => { + expect(comp.normalizeText('Hello World')).toBe('Hello World'); + }); + + it('should replace the ß HTML entity', (): void => { + const result = comp.normalizeText('Stra\u00DFe'); + expect(result).toContain('\u00DF'); + }); + + it('should replace the ö HTML entity', (): void => { + const result = comp.normalizeText('P\u00F6chlarn'); + expect(result).toContain('\u00F6'); + }); + + it('should replace the ä HTML entity', (): void => { + const result = comp.normalizeText('st\u00E4dtisch'); + expect(result).toContain('\u00E4'); + }); + + it('should replace the ü HTML entity', (): void => { + const result = comp.normalizeText('M\u00FCnchen'); + expect(result).toContain('\u00FC'); + }); + + it('should replace the Ä HTML entity', (): void => { + const result = comp.normalizeText('\u00C4gypten'); + expect(result).toContain('\u00C4'); + }); + + it('should replace the Ü HTML entity', (): void => { + const result = comp.normalizeText('\u00DCbung'); + expect(result).toContain('\u00DC'); + }); + + it('should handle an empty string', (): void => { + expect(comp.normalizeText('')).toBe(''); + }); + }); + + describe('Address display properties', (): void => { + it('should produce a displayStreet that is a non-empty string', (): void => { + expect(comp.displayStreet.length).toBeGreaterThan(0); + }); + + it('should produce a displayCity that is a non-empty string', (): void => { + expect(comp.displayCity.length).toBeGreaterThan(0); + }); + + it('should not leave unresolved HTML entities in displayStreet', (): void => { + expect(comp.displayStreet).not.toMatch(/&[a-z]+;/); + }); + + it('should not leave unresolved HTML entities in displayCity', (): void => { + expect(comp.displayCity).not.toMatch(/&[a-z]+;/); + }); + }); + + describe('Template', (): void => { + it('should render the author firstname', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain(global.firstname); + }); + + it('should render the author lastname', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain(global.lastname); + }); + + it('should render the country in the address block', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain(global.address.country); + }); + + it('should render the contact email as visible text', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain(global.contactMail); + }); + + it('should render the abuse email as visible text', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain(global.abuseMail); + }); + + it('should render the phone number', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain(global.contactPhone); + }); + + it('should render an "Imprint & Privacy" heading', (): void => { + const el = fixture.nativeElement as HTMLElement; + const headings = Array.from(el.querySelectorAll('h1')); + expect(headings.some((h): boolean => h.textContent?.includes('Imprint') === true)).toBeTrue(); + }); + + it('should contain a back link to the portfolio root', (): void => { + const el = fixture.nativeElement as HTMLElement; + const backLink = el.querySelector('a.back-link'); + expect(backLink).not.toBeNull(); + }); + }); + + describe('Performance', (): void => { + it('should create, initialize and run change detection within 100 ms', (): void => { + const start = performance.now(); + const f = TestBed.createComponent(ImprintComponent); + f.detectChanges(); + expect(performance.now() - start).toBeLessThan(100); + }); + + it('should complete 500 normalizeText calls within 20 ms', (): void => { + const input = 'Ulmenstra\u00DFe \u00F6sterreich \u00E4hnlich \u00FCberall \u00C4gypten \u00DCbung'; + const start = performance.now(); + for (let i = 0; i < 500; i++) { + comp.normalizeText(input); + } + expect(performance.now() - start).toBeLessThan(20); + }); + }); });