Files
Personal-Portfolio-Website/src/app/home/home.component.spec.ts
T

565 lines
21 KiB
TypeScript

import type { Mock } from 'vitest';
import type { ComponentFixture } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { NgZone } from '@angular/core';
import type { Signal, WritableSignal } from '@angular/core';
import { HomeComponent } from './home.component';
import { global } from '../../global';
import { enLanguage } from '../../languages/en';
import type { LanguagePortraitHighlight } from '../../languages/language.types';
describe('HomeComponent', (): void => {
beforeEach((): void => {
// performance stays real so the change detection timing tests keep measuring.
vi.useFakeTimers({
toFake: [
'setTimeout',
'clearTimeout',
'setInterval',
'clearInterval',
'requestAnimationFrame',
'cancelAnimationFrame',
],
});
// jsdom does not implement matchMedia.
vi.stubGlobal('matchMedia', (): MediaQueryList => ({ matches: false }) as MediaQueryList);
});
afterEach((): void => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
let fixture: ComponentFixture<HomeComponent>;
let component: HomeComponent;
interface ComponentAccess {
age: number;
currentIndex: WritableSignal<number>;
isBioTickerPaused: WritableSignal<boolean>;
isLongBioShown: WritableSignal<boolean>;
isLongBioMounted: WritableSignal<boolean>;
isPortraitSwitching: WritableSignal<boolean>;
currentPortraitHighlightIndex: WritableSignal<number>;
portraitHighlights: Signal<LanguagePortraitHighlight[]>;
bioHideTimeoutId: number | null;
scrollEffectsAnimationFrameId: number | null;
scheduleScrollEffectsUpdate(): void;
shouldEnableScrollEffects(): boolean;
toggleBio(): void;
showNextPortraitHighlight(): void;
scrollToAboutSection(): void;
scrollToProjectsSection(): void;
scrollToContactLinks(): void;
currentBioText: Signal<string>;
currentPortraitHighlight: Signal<LanguagePortraitHighlight | null>;
}
let comp: ComponentAccess;
beforeEach(async (): Promise<void> => {
await TestBed.configureTestingModule({
imports: [HomeComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
comp = component as unknown as ComponentAccess;
fixture.detectChanges();
});
afterEach((): void => {
// Explicitly destroy to stop the bio ticker interval started after the first render.
// Without this, the interval keeps firing after the test has finished.
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('currentBioText', (): void => {
it('should return the first bioTextsList entry at index 0', (): void => {
comp.currentIndex.set(0);
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
});
it('should return the correct entry when index changes', (): void => {
comp.currentIndex.set(3);
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[3]);
});
it('should return an empty text when index is out of range', (): void => {
comp.currentIndex.set(99999);
expect(comp.currentBioText()).toBe('');
});
});
describe('interval-driven bio cycling', (): void => {
it('should advance to the next entry every 1250 ms', async (): Promise<void> => {
await vi.advanceTimersByTimeAsync(1249);
expect(comp.currentIndex()).toBe(0);
await vi.advanceTimersByTimeAsync(1);
expect(comp.currentIndex()).toBe(1);
});
it('should pause while the bio is hovered', async (): Promise<void> => {
const meta = (fixture.nativeElement as HTMLElement).querySelector('.hero-meta');
meta?.dispatchEvent(new MouseEvent('mouseenter'));
await vi.advanceTimersByTimeAsync(2500);
expect(comp.currentIndex()).toBe(0);
meta?.dispatchEvent(new MouseEvent('mouseleave'));
await vi.advanceTimersByTimeAsync(1250);
expect(comp.currentIndex()).toBe(1);
});
it('should not cycle when reduced motion is requested', async (): Promise<void> => {
vi.stubGlobal('matchMedia', (): MediaQueryList => ({ matches: true }) as MediaQueryList);
const reducedFixture = TestBed.createComponent(HomeComponent);
reducedFixture.detectChanges();
const reduced = reducedFixture.componentInstance as unknown as ComponentAccess;
await vi.advanceTimersByTimeAsync(5000);
expect(reduced.currentIndex()).toBe(0);
reducedFixture.destroy();
});
it('should start with currentIndex 0', (): void => {
expect(comp.currentIndex()).toBe(0);
});
it('should expose a valid bio entry for the current index', (): void => {
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
});
it('should correctly wrap from the last entry back to 0 using modulo', (): void => {
const last = enLanguage.bioTextsList.length - 1;
comp.currentIndex.set(last);
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[last]);
comp.currentIndex.set((last + 1) % enLanguage.bioTextsList.length);
expect(comp.currentIndex()).toBe(0);
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
});
});
// ── Portrait highlight ─────────────────────────────────────────────────────
describe('currentPortraitHighlight getter', (): void => {
it('should return the highlight at the current index', (): void => {
comp.currentPortraitHighlightIndex.set(0);
expect(comp.currentPortraitHighlight()).toEqual(enLanguage.portraitHighlights[0]);
});
it('should return null when index is out of range', (): void => {
comp.currentPortraitHighlightIndex.set(99999);
expect(comp.currentPortraitHighlight()).toBeNull();
});
});
describe('portrait preloading', (): void => {
it('should preload every highlight image except the visible one', (): void => {
const preloaded: string[] = [];
vi.spyOn(window, 'Image').mockImplementation(function (): HTMLImageElement {
const image = document.createElement('img');
Object.defineProperty(image, 'src', {
set: (value: string): void => {
preloaded.push(value);
},
});
return image;
});
const preloadFixture = TestBed.createComponent(HomeComponent);
preloadFixture.detectChanges();
expect(preloaded).toEqual(enLanguage.portraitHighlights.slice(1).map((h) => h.image));
preloadFixture.destroy();
});
});
describe('showNextPortraitHighlight', (): void => {
it('should set isPortraitSwitching to true immediately', (): void => {
comp.isPortraitSwitching.set(false);
comp.showNextPortraitHighlight();
expect(comp.isPortraitSwitching()).toBe(true);
});
it('should not advance index when already switching', (): void => {
comp.isPortraitSwitching.set(true);
comp.showNextPortraitHighlight();
expect(comp.currentPortraitHighlightIndex()).toBe(0);
});
it('should not call setTimeout when only one portrait exists', (): void => {
const highlights = comp.portraitHighlights();
const saved = [...highlights]; // snapshot before mutation
highlights.splice(1, saved.length - 1); // reduce to a single entry
const setSpy = vi.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', async (): Promise<void> => {
const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight();
});
await vi.advanceTimersByTimeAsync(125);
expect(comp.currentPortraitHighlightIndex()).toBe(1);
await vi.advanceTimersByTimeAsync(125);
});
it('should reset isPortraitSwitching to false after 250 ms', async (): Promise<void> => {
const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight();
});
await vi.advanceTimersByTimeAsync(250);
expect(comp.isPortraitSwitching()).toBe(false);
});
it('should wrap portrait index from the last position back to 0', async (): Promise<void> => {
comp.currentPortraitHighlightIndex.set(enLanguage.portraitHighlights.length - 1);
const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight();
});
await vi.advanceTimersByTimeAsync(125);
expect(comp.currentPortraitHighlightIndex()).toBe(0);
await vi.advanceTimersByTimeAsync(125);
});
});
// ── Bio toggle ─────────────────────────────────────────────────────────────
describe('toggleBio', (): void => {
it('should mount bio immediately on opening', async (): Promise<void> => {
comp.toggleBio();
expect(comp.isLongBioMounted()).toBe(true);
await vi.advanceTimersByTimeAsync(100);
});
it('should set isLongBioShown to true after the double-rAF delay', async (): Promise<void> => {
comp.toggleBio();
await vi.advanceTimersByTimeAsync(50); // allow both nested requestAnimationFrame calls to execute
fixture.detectChanges();
expect(comp.isLongBioShown()).toBe(true);
});
it('should set isLongBioShown to false immediately on close', async (): Promise<void> => {
// Open first
comp.toggleBio();
await vi.advanceTimersByTimeAsync(50);
fixture.detectChanges();
// Close
comp.toggleBio();
fixture.detectChanges();
expect(comp.isLongBioShown()).toBe(false);
await vi.advanceTimersByTimeAsync(300);
});
it('should keep isLongBioMounted true during the 300 ms close animation', async (): Promise<void> => {
comp.toggleBio();
await vi.advanceTimersByTimeAsync(50);
fixture.detectChanges();
comp.toggleBio();
fixture.detectChanges();
expect(comp.isLongBioMounted()).toBe(true);
await vi.advanceTimersByTimeAsync(299);
expect(comp.isLongBioMounted()).toBe(true);
await vi.advanceTimersByTimeAsync(1);
fixture.detectChanges();
expect(comp.isLongBioMounted()).toBe(false);
});
it('should cancel pending close timeout when re-opened during animation', async (): Promise<void> => {
comp.toggleBio();
await vi.advanceTimersByTimeAsync(50);
fixture.detectChanges();
comp.toggleBio(); // start closing
await vi.advanceTimersByTimeAsync(150); // halfway through 300 ms
comp.toggleBio(); // re-open before unmount
await vi.advanceTimersByTimeAsync(50);
fixture.detectChanges();
expect(comp.isLongBioMounted()).toBe(true);
expect(comp.isLongBioShown()).toBe(true);
await vi.advanceTimersByTimeAsync(500);
});
});
// ── Scroll helpers ─────────────────────────────────────────────────────────
describe('scroll helpers', (): void => {
let scrollSpy: Mock;
beforeEach((): void => {
scrollSpy = vi.spyOn(window, 'scrollTo').mockReturnValue(undefined);
});
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 clear pending portrait switch timers on destroy', async (): Promise<void> => {
comp.showNextPortraitHighlight();
const clearSpy = vi.spyOn(window, 'clearTimeout');
fixture.destroy();
expect(clearSpy).toHaveBeenCalled();
});
it('should not throw when destroyed', async (): Promise<void> => {
expect((): void => fixture.destroy()).not.toThrow();
});
});
// ── 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 link the skills box to the LinkedIn skills page', (): void => {
const el = fixture.nativeElement as HTMLElement;
const link = el.querySelector<HTMLAnchorElement>('a.hero-meta');
expect(link?.getAttribute('href')).toBe(global.linkedinSkillsUrl);
});
it('should render the skills label above the rotating skill', (): 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 localized project entry', (): void => {
const el = fixture.nativeElement as HTMLElement;
const entries = el.querySelectorAll('.project-entry');
expect(entries.length).toBe(enLanguage.projects.length);
});
it('should keep the arrow on the same line as the last title word', (): void => {
const el = fixture.nativeElement as HTMLElement;
const tails = Array.from(el.querySelectorAll('.project-title-tail'));
expect(tails.map((tail): string => tail.textContent?.trim() ?? '')).toEqual(
enLanguage.projects.map((project): string => project.title.split(' ').at(-1) ?? ''),
);
tails.forEach((tail): void => {
expect(tail.querySelector('.project-arrow')).not.toBeNull();
});
});
it('should render all project titles', (): void => {
const el = fixture.nativeElement as HTMLElement;
enLanguage.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 keep scroll effects enabled on desktop pointers when motion is allowed', (): void => {
vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string): MediaQueryList =>
({
matches:
query !== '(prefers-reduced-motion: reduce)' &&
query !== '(hover: none) and (pointer: coarse)',
}) as MediaQueryList,
);
expect(comp.shouldEnableScrollEffects()).toBe(true);
});
it('should keep scroll effects enabled on coarse touch pointers when motion is allowed', (): void => {
vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string): MediaQueryList =>
({
matches: query === '(hover: none) and (pointer: coarse)',
}) as MediaQueryList,
);
expect(comp.shouldEnableScrollEffects()).toBe(true);
});
it('should disable scroll effects when reduced motion is requested', (): void => {
vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string): MediaQueryList =>
({
matches: query === '(prefers-reduced-motion: reduce)',
}) as MediaQueryList,
);
expect(comp.shouldEnableScrollEffects()).toBe(false);
});
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 batch all scroll effects into a single requestAnimationFrame', async (): Promise<void> => {
// The RAF registered after the first render is still pending under fake timers.
// Resetting the ID to null makes the schedule function treat the slot as free.
comp.scrollEffectsAnimationFrameId = null;
let rafCallCount = 0;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => {
rafCallCount++;
return rafCallCount; // returns non-null so subsequent calls deduplicate
});
comp.scheduleScrollEffectsUpdate();
comp.scheduleScrollEffectsUpdate();
comp.scheduleScrollEffectsUpdate();
expect(rafCallCount).toBe(1);
});
});
});