232 lines
8.9 KiB
TypeScript
232 lines
8.9 KiB
TypeScript
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<AboutComponent>;
|
|
|
|
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('<svg></svg>');
|
|
|
|
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: `<mat-card><mat-card-title>probe</mat-card-title></mat-card>`,
|
|
})
|
|
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);
|
|
});
|
|
});
|