feat: refine portfolio content, contact, and supporting media
This commit is contained in:
@@ -62,10 +62,10 @@ describe('semantic portfolio document', () => {
|
||||
'System Administrator & Full-Stack Developer',
|
||||
validInputs.content.identity.valueProposition,
|
||||
validInputs.content.identity.availability,
|
||||
'Email Luca',
|
||||
'Contact Me',
|
||||
'Explore work',
|
||||
]);
|
||||
expect(hero.querySelector('a[href="mailto:hello@example.test"]')?.textContent).toBe('Email Luca');
|
||||
expect(hero.querySelector('a[href="#contact"]')?.textContent).toBe('Contact Me');
|
||||
expect(document.querySelector('[data-featured-operation]')?.textContent).toContain(
|
||||
validInputs.content.infrastructure.featuredOperation.title,
|
||||
);
|
||||
@@ -94,8 +94,10 @@ describe('semantic portfolio document', () => {
|
||||
expect([...navigation.querySelectorAll('a')].map((link) => link.getAttribute('href'))).toEqual([
|
||||
'#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact',
|
||||
]);
|
||||
const email = document.querySelector('a[href="mailto:hello@example.test"]');
|
||||
expect(email?.textContent).toBe('Email Luca');
|
||||
const email = document.querySelector('#contact-email-details');
|
||||
expect(email?.querySelector('summary')?.textContent).toBe('Email Me');
|
||||
expect(email?.textContent).toContain('hello [at] example [dot] test');
|
||||
expect(document.querySelector('a[href^="mailto:"]')).toBeNull();
|
||||
expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.');
|
||||
});
|
||||
|
||||
|
||||
@@ -160,6 +160,10 @@ describe('ProjectMedia', () => {
|
||||
|
||||
const video = wrapper.get('video');
|
||||
expect(video.attributes('preload')).toBe('none');
|
||||
expect(video.attributes()).toHaveProperty('loop');
|
||||
expect(video.attributes()).toHaveProperty('muted');
|
||||
expect(video.attributes()).not.toHaveProperty('autoplay');
|
||||
expect(wrapper.get('.video-fallback').attributes('href')).toBe('/assets/project-loop.mp4');
|
||||
expect(video.attributes()).not.toHaveProperty('controls');
|
||||
expect(video.findAll('source')).toHaveLength(0);
|
||||
expect(wrapper.get('button').text()).toBe('Play video');
|
||||
@@ -175,15 +179,16 @@ describe('ProjectMedia', () => {
|
||||
'/assets/project-loop.mp4',
|
||||
]);
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledOnce();
|
||||
expect(HTMLMediaElement.prototype.load).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('attaches inside 300 CSS pixels when automatic media is allowed and detaches offscreen', async () => {
|
||||
it('attaches in the viewport when automatic media is allowed and detaches offscreen', async () => {
|
||||
const wrapper = mountMedia({
|
||||
kind: 'video', assetId: 'project-loop', posterAssetId: 'project-loop-poster',
|
||||
alt: 'Gameplay excerpt.', caption: 'Gameplay.', longDescription: 'A gameplay loop.',
|
||||
}, true);
|
||||
const observer = IntersectionObserverFake.instances[0]!;
|
||||
expect(observer.options?.rootMargin).toBe('300px 0px 300px 0px');
|
||||
expect(observer.options?.rootMargin).toBe('0px');
|
||||
|
||||
observer.emit(true);
|
||||
await nextTick();
|
||||
@@ -238,6 +243,8 @@ describe('ProjectMediaGallery', () => {
|
||||
expect(mediaProjects.map(({ id }) => id)).toEqual([
|
||||
'private-medication-tracker',
|
||||
'opinion-dynamics-simulation',
|
||||
'private-game-prototype',
|
||||
'private-healthcare-platform',
|
||||
'currency-converter',
|
||||
]);
|
||||
|
||||
@@ -247,7 +254,7 @@ describe('ProjectMediaGallery', () => {
|
||||
}));
|
||||
const images = wrappers.flatMap((wrapper) => wrapper.findAll('img'));
|
||||
|
||||
expect(images).toHaveLength(6);
|
||||
expect(images).toHaveLength(7);
|
||||
expect(images.every((image) => image.attributes('loading') === 'lazy')).toBe(true);
|
||||
expect(images.every((image) => image.attributes('decoding') === 'async')).toBe(true);
|
||||
expect(images.every((image) => Number(image.attributes('width')) > 0 && Number(image.attributes('height')) > 0)).toBe(true);
|
||||
@@ -345,7 +352,7 @@ describe('media content schema', () => {
|
||||
additional: [{ id: 'extra', title: 'Extra', visibility: 'public', maturity: 'learning', summary: 'S.', problem: 'P.', contribution: 'C.', technicalDecisions: ['D.'], outcome: 'O.', assetIds: [], media: [] }],
|
||||
},
|
||||
toolkit: [{ title: 'Tools', technologies: [{ name: 'Linux' }] }],
|
||||
contact: { introduction: 'Open to roles.', email: 'hello@example.test', social: [], cvMessage: 'Contact directly.' },
|
||||
contact: { introduction: 'Open to roles.', email: { localPart: 'hello', domain: 'example.test' }, social: [], cvMessage: 'Contact directly.' },
|
||||
metadata: { title: 'Title.', description: 'Description.' },
|
||||
sceneDescription: 'Scene.',
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,7 @@ const socialLabels = content.contact.social.map((link) => link.service);
|
||||
|
||||
describe('official brand icon registry', () => {
|
||||
it('resolves every toolkit technology with an honest official mark', () => {
|
||||
for (const label of toolkitLabels.filter(brandIconAvailable)) {
|
||||
for (const label of toolkitLabels) {
|
||||
const icon = brandIconFor(label);
|
||||
expect(icon.title).toBeTruthy();
|
||||
expect(icon.hex).toMatch(/^[0-9A-F]{6}$/u);
|
||||
@@ -31,8 +31,9 @@ describe('official brand icon registry', () => {
|
||||
'Git', 'Python', 'TypeScript/JavaScript', 'Vue.js', 'Flutter/Dart', 'Fastify',
|
||||
'C#/.NET', 'Headless WordPress', 'Java', 'PostgreSQL', 'Drift/SQLite',
|
||||
'Drizzle ORM', 'PyTorch', 'Ultralytics/YOLO', 'OpenCV', 'GitHub', 'GitLab', 'LinkedIn',
|
||||
'DNS', 'TLS', 'Fail2Ban', 'SQL',
|
||||
]);
|
||||
expect(['DNS', 'TLS', 'Fail2Ban', 'Btrfs', 'rsync', 'SQL', 'SPSS', 'Microsoft Excel', 'SoccerNet']
|
||||
expect(['Btrfs', 'rsync', 'SPSS', 'Microsoft Excel', 'SoccerNet']
|
||||
.every((label) => !brandIconAvailable(label))).toBe(true);
|
||||
expect(() => brandIconFor('Unlisted technology')).toThrow(/No official brand icon/u);
|
||||
});
|
||||
|
||||
@@ -163,21 +163,21 @@ describe("semantic section components", () => {
|
||||
expect(tile.findAll('[aria-label="Linux"]').length).toBe(0);
|
||||
});
|
||||
|
||||
it("renders a generic toolkit concept as visible text without inventing an icon", () => {
|
||||
it("renders a toolkit concept with a decorative vector symbol", () => {
|
||||
const wrapper = mount(ToolkitGrid, {
|
||||
props: { groups: [{ title: "Systems", technologies: [{ name: "DNS" }] }] },
|
||||
});
|
||||
const tile = wrapper.get('[data-testid="toolkit-technology"]');
|
||||
|
||||
expect(tile.text()).toBe("DNS");
|
||||
expect(tile.find("svg").exists()).toBe(false);
|
||||
expect(tile.get("span").classes()).toContain("toolkit-item__label--text-only");
|
||||
expect(tile.find("svg").exists()).toBe(true);
|
||||
expect(tile.get("svg").attributes('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it("renders a visible mailto contact and safe verified social links", () => {
|
||||
it("keeps email behind an interaction and renders safe social links", () => {
|
||||
const contact: ContactContent = {
|
||||
introduction: "Open to System Administrator and Full-Stack Developer opportunities.",
|
||||
email: "hello@example.test",
|
||||
email: { localPart: "hello", domain: "example.test" },
|
||||
cvMessage:
|
||||
"For a current CV or role-specific information, contact me directly.",
|
||||
social: [{ service: "GitHub", url: "https://github.com/example" }],
|
||||
@@ -188,9 +188,8 @@ describe("semantic section components", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.get('a[href="mailto:hello@example.test"]').text()).toBe(
|
||||
"Email Luca",
|
||||
);
|
||||
expect(wrapper.find('a[href^="mailto:"]').exists()).toBe(false);
|
||||
expect(wrapper.get('summary').text()).toBe('Email Me');
|
||||
expect(wrapper.text()).toContain(contact.introduction);
|
||||
const social = wrapper.get('a[href="https://github.com/example"]');
|
||||
expect(social.text()).toBe("GitHub");
|
||||
|
||||
52
tests/unit/contact-interaction.test.ts
Normal file
52
tests/unit/contact-interaction.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import ContactDestination from '@/components/ContactDestination.vue';
|
||||
import { parsePortfolioContent } from '@/content/schemas';
|
||||
import content from '@/../content/public/portfolio-content.json';
|
||||
|
||||
const contact = parsePortfolioContent(content).contact;
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('contact disclosure', () => {
|
||||
it('assembles a mail link only after pointer or keyboard interaction', async () => {
|
||||
const wrapper = mount(ContactDestination, { props: { contact } });
|
||||
await nextTick();
|
||||
expect(wrapper.find('a[href^="mailto:"]').exists()).toBe(false);
|
||||
expect(wrapper.get('.contact-email__preview').text()).toBe('');
|
||||
await wrapper.get('.contact-email__action').trigger('focusin');
|
||||
expect(wrapper.get('a.contact-link').attributes('href')).toBe('mailto:luca.pislaru@gmail.com');
|
||||
expect(wrapper.get('a.contact-link').text()).toBe('Email Me');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('reveals a copyable address through the native disclosure', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } });
|
||||
const wrapper = mount(ContactDestination, { props: { contact } });
|
||||
await nextTick();
|
||||
const details = wrapper.get('details');
|
||||
(details.element as HTMLDetailsElement).open = true;
|
||||
await details.trigger('toggle');
|
||||
expect(wrapper.get('summary').text()).toBe('Hide email');
|
||||
expect(wrapper.get('.contact-email__address').text()).toBe('luca.pislaru@gmail.com');
|
||||
await wrapper.get('.contact-email__copy').trigger('click');
|
||||
expect(writeText).toHaveBeenCalledWith('luca.pislaru@gmail.com');
|
||||
expect(wrapper.get('[role="status"]').text()).toBe('Copied');
|
||||
await wrapper.get('.contact-email').trigger('keydown', { key: 'Escape' });
|
||||
expect((details.element as HTMLDetailsElement).open).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps the address selectable when clipboard permission is unavailable', async () => {
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Unavailable')) } });
|
||||
const wrapper = mount(ContactDestination, { props: { contact } });
|
||||
await nextTick();
|
||||
(wrapper.get('details').element as HTMLDetailsElement).open = true;
|
||||
await wrapper.get('details').trigger('toggle');
|
||||
await wrapper.get('.contact-email__copy').trigger('click');
|
||||
expect(wrapper.get('[role="status"]').text()).toBe('Select the address to copy it.');
|
||||
expect(wrapper.get('.contact-email__address').text()).toContain('@');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe('safe metadata', () => {
|
||||
|
||||
expect(metadata.title).toBe('Luca Pîslaru — System Administrator & Full-Stack Developer');
|
||||
expect(metadata.description).toBe(
|
||||
'Portfolio of Luca Pîslaru, a Bucharest-based System Administrator and Full-Stack Developer working across Linux infrastructure and end-to-end software delivery.',
|
||||
'I’m Luca Pîslaru, a System Administrator & Full-Stack Developer based in Bucharest. Explore my work in Linux infrastructure and end-to-end application development.',
|
||||
);
|
||||
expect(metadata.canonicalUrl).toBe('https://portfolio.example.test/');
|
||||
const previewAsset = manifest.assets.find(({ id }) => id === content.metadata.socialPreviewAssetId)!;
|
||||
@@ -59,7 +59,6 @@ describe('safe metadata', () => {
|
||||
jobTitle: 'System Administrator & Full-Stack Developer',
|
||||
description: metadata.description,
|
||||
url: 'https://portfolio.example.test/',
|
||||
email: 'mailto:luca.pislaru@gmail.com',
|
||||
homeLocation: {
|
||||
'@type': 'Place',
|
||||
address: {
|
||||
@@ -69,14 +68,14 @@ describe('safe metadata', () => {
|
||||
},
|
||||
},
|
||||
sameAs: [
|
||||
'https://linkedin.com/in/luca-pisl',
|
||||
'https://github.com/LucaPisl',
|
||||
'https://gitlab.com/luca_pisl',
|
||||
'https://linkedin.com/in/luca-pisl',
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(metadata.jsonLd)).not.toMatch(
|
||||
/streetAddress|postalCode|telephone|interactionStatistic|"image"/iu,
|
||||
/streetAddress|postalCode|telephone|interactionStatistic|"image"|"email"/iu,
|
||||
);
|
||||
const expectedHash = createHash('sha256').update(metadata.serializedJsonLd).digest('base64');
|
||||
expect(metadata.jsonLdCspHash).toBe(`sha256-${expectedHash}`);
|
||||
|
||||
Reference in New Issue
Block a user