import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Component, provideZonelessChangeDetection } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { By, DomSanitizer } from '@angular/platform-browser'; import { MatIconRegistry } from '@angular/material/icon'; import { provideRouter } from '@angular/router'; import { provideTranslateService } from '@ngx-translate/core'; import { AboutComponent } from './about.component'; /** * No translations are loaded, so the translate pipe echoes the key back. That is * exactly what these tests want: they assert which key is rendered where, which * is what the mismatched skill headings got wrong. Expected values are written * out literally rather than read off the component, so a change to the component * cannot silently move the assertion with it. */ describe('AboutComponent', () => { let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [AboutComponent], providers: [ provideZonelessChangeDetection(), provideRouter([]), provideTranslateService({ lang: 'en', fallbackLang: 'en' }), ], }).compileComponents(); registerStubIcons(); fixture = TestBed.createComponent(AboutComponent); fixture.detectChanges(); }); /** * The real SVG icons are registered by an app initializer that TestBed never * runs, so mat-icon logs a lookup error for every fixture. Literals keep the * registry satisfied without pulling HttpClient into these tests. */ function registerStubIcons(): void { const iconRegistry = TestBed.inject(MatIconRegistry); const sanitizer = TestBed.inject(DomSanitizer); const emptyIcon = sanitizer.bypassSecurityTrustHtml(''); iconRegistry.addSvgIconLiteral('linkedin', emptyIcon); iconRegistry.addSvgIconLiteral('github', emptyIcon); } function textOf(selector: string): string[] { return fixture.debugElement .queryAll(By.css(selector)) .map(element => element.nativeElement.textContent.trim()); } function click(testId: string): void { fixture.debugElement .query(By.css(`[data-testid="${testId}"]`)) .nativeElement.click(); fixture.detectChanges(); } function skillGroupTexts(): { heading: string; chips: string[] }[] { return fixture.debugElement.queryAll(By.css('mat-chip-set')).map(chipSet => { const group = chipSet.parent!; const heading = group.query(By.css('h3')).nativeElement.textContent.trim(); const chips = group .queryAll(By.css('mat-chip')) .map(chip => chip.nativeElement.textContent.trim()); return { heading, chips }; }); } describe('skills', () => { it('renders the skill groups in the documented order', () => { expect(skillGroupTexts().map(group => group.heading)).toEqual([ 'ABOUT.SECTION.LANGUAGES', 'ABOUT.SECTION.ARCH_CLOUD', 'ABOUT.SECTION.SIM_ALGO', 'ABOUT.SECTION.AI_ENG', ]); }); it('lists languages under the languages heading, not under architecture', () => { const languages = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.LANGUAGES'); expect(languages).toBeDefined(); expect(languages!.chips).toContain('ABOUT.SKILLS.JAVA'); expect(languages!.chips).not.toContain('ABOUT.TOOLS.DOCKER'); }); it('lists infrastructure tools under the architecture heading', () => { const architecture = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.ARCH_CLOUD'); expect(architecture).toBeDefined(); expect(architecture!.chips).toContain('ABOUT.TOOLS.DOCKER'); expect(architecture!.chips).not.toContain('ABOUT.SKILLS.JAVA'); }); it('advertises AI-assisted engineering as its own group', () => { const ai = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.AI_ENG'); expect(ai).toBeDefined(); expect(ai!.chips).toContain('ABOUT.SKILLS.AI_HARNESS'); }); }); describe('experience', () => { it('hides the pre-2010 positions until they are requested', () => { expect(textOf('[data-testid="role"]')).toContain('ABOUT.XP.COMPANY9.ROLE'); expect(textOf('[data-testid="role"]')).not.toContain('ABOUT.XP.COMPANY0.ROLE'); }); it('reveals the earlier positions when the toggle button is clicked', () => { click('toggle-experience'); expect(textOf('[data-testid="role"]')).toContain('ABOUT.XP.COMPANY0.ROLE'); }); it('reports the expanded state of the experience toggle to assistive technology', () => { const toggle = fixture.debugElement.query(By.css('[data-testid="toggle-experience"]')).nativeElement; expect(toggle.getAttribute('aria-expanded')).toBe('false'); click('toggle-experience'); expect(toggle.getAttribute('aria-expanded')).toBe('true'); }); it('draws the employer header once per run of consecutive roles', () => { const companies = textOf('[data-testid="company"]'); // COMPANY5/4/3 are three consecutive Assyst roles and share one header. expect(companies).toContain('ABOUT.XP.COMPANY5.COMPANY'); expect(companies).not.toContain('ABOUT.XP.COMPANY4.COMPANY'); expect(companies).not.toContain('ABOUT.XP.COMPANY3.COMPANY'); }); it('treats the two ColorDigital spells as separate blocks so the timeline stays chronological', () => { const companies = textOf('[data-testid="company"]'); expect(companies).toContain('ABOUT.XP.COMPANY9.COMPANY'); expect(companies).toContain('ABOUT.XP.COMPANY7.COMPANY'); }); it('keeps the employer header at the seam when the earlier roles are appended', () => { click('toggle-experience'); // COMPANY3 (Assyst) and COMPANY2 (TH Bingen) meet at the seam, so the // first earlier role must still get its own header. expect(textOf('[data-testid="company"]')).toContain('ABOUT.XP.COMPANY2.COMPANY'); }); }); describe('education', () => { it('shows only the post-school entries until the toggle button is clicked', () => { expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E6.WHERE'); expect(textOf('[data-testid="education-where"]')).not.toContain('ABOUT.EDUCATION.E0.WHERE'); click('toggle-education'); expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E0.WHERE'); }); }); describe('call to action', () => { it('offers no download link, so nothing can point at a missing file', () => { expect(fixture.debugElement.queryAll(By.css('a[download]')).length).toBe(0); }); it('exposes the mail contact as a button rather than an empty link', () => { const emptyLinks = fixture.debugElement .queryAll(By.css('a')) .filter(anchor => anchor.nativeElement.getAttribute('href') === ''); expect(emptyLinks.length).toBe(0); expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy(); }); it('keeps the mail button in the same font as the links it sits next to', () => { const mailButton = fixture.debugElement.query(By.css('[data-testid="contact-mail"]')).nativeElement; const siblingLink = fixture.debugElement .queryAll(By.css('a')) .find(anchor => anchor.nativeElement.textContent.trim() === 'Gitea')!.nativeElement; const buttonStyle = getComputedStyle(mailButton); const linkStyle = getComputedStyle(siblingLink); expect(buttonStyle.fontSize).toBe(linkStyle.fontSize); expect(buttonStyle.fontFamily).toBe(linkStyle.fontFamily); }); }); }); /** * Material typography (`brand-family` in styles.scss) and the Tailwind body * stack are declared in two separate places and are easy to change apart. When * they disagree, only visitors who happen to have the extra font installed * locally see it, so it will not show up in review. */ describe('global font stack', () => { @Component({ imports: [MatCardModule], template: `probe`, }) class FontProbeComponent {} function normalizeStack(value: string): string { return value .replace(/["']/g, '') .split(',') .map(family => family.trim()) .filter(family => family.length > 0) .join(', '); } it('resolves Material card titles to the same font stack as body copy', async () => { await TestBed.configureTestingModule({ imports: [FontProbeComponent], providers: [provideZonelessChangeDetection()], }).compileComponents(); const probe = TestBed.createComponent(FontProbeComponent); probe.detectChanges(); const title = probe.nativeElement.querySelector('.mat-mdc-card-title') as HTMLElement; const titleStack = normalizeStack(getComputedStyle(title).fontFamily); const bodyStack = normalizeStack(getComputedStyle(document.body).fontFamily); expect(titleStack).toBe(bodyStack); }); });