Files
Portfolio/tests/unit/ProjectMedia.test.ts

337 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mount } from '@vue/test-utils';
import { nextTick, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { productionInputsKey } from '@/app';
import AccessibleVideo from '@/components/AccessibleVideo.vue';
import ProjectMedia from '@/components/ProjectMedia.vue';
import ProjectMediaGallery from '@/components/ProjectMediaGallery.vue';
import { useLazyMedia } from '@/composables/useLazyMedia';
import { parsePortfolioContent, parsePublicAssetManifest } from '@/content/schemas';
import type { MediaPresentation, ProductionInputs, PublicAssetManifest } from '@/content/types';
const sha = 'a'.repeat(64);
const manifest: PublicAssetManifest = {
assets: [
{
id: 'project-image', fileName: 'project-image.webp', sha256: sha, mediaType: 'image/webp',
width: 1200, height: 800, durationMs: null, rightsHolder: 'Luca Pîslaru',
license: 'All rights reserved', attribution: 'Luca Pîslaru',
},
{
id: 'project-chart', fileName: 'project-chart.webp', sha256: sha, mediaType: 'image/webp',
width: 1000, height: 700, durationMs: null, rightsHolder: 'Luca Pîslaru',
license: 'All rights reserved', attribution: 'Luca Pîslaru',
},
{
id: 'project-portrait', fileName: 'project-portrait.webp', sha256: sha, mediaType: 'image/webp',
width: 800, height: 1200, durationMs: null, rightsHolder: 'Luca Pîslaru',
license: 'All rights reserved', attribution: 'Luca Pîslaru',
},
{
id: 'project-loop', fileName: 'project-loop.webm', sha256: sha, mediaType: 'video/webm',
width: 1280, height: 720, durationMs: 5_000, rightsHolder: 'Luca Pîslaru',
license: 'All rights reserved', attribution: 'Luca Pîslaru',
},
{
id: 'project-loop-mp4', fileName: 'project-loop.mp4', sha256: sha, mediaType: 'video/mp4',
width: 1280, height: 720, durationMs: 5_000, rightsHolder: 'Luca Pîslaru',
license: 'All rights reserved', attribution: 'Luca Pîslaru',
},
{
id: 'project-loop-poster', fileName: 'project-loop-poster.webp', sha256: sha, mediaType: 'image/webp',
width: 1280, height: 720, durationMs: null, rightsHolder: 'Luca Pîslaru',
license: 'All rights reserved', attribution: 'Luca Pîslaru',
},
],
};
const injectedInputs = {
manifest,
content: {},
site: {},
} as unknown as ProductionInputs;
class IntersectionObserverFake {
static instances: IntersectionObserverFake[] = [];
readonly observe = vi.fn();
readonly disconnect = vi.fn();
constructor(
readonly callback: IntersectionObserverCallback,
readonly options?: IntersectionObserverInit,
) {
IntersectionObserverFake.instances.push(this);
}
unobserve(): void {}
takeRecords(): IntersectionObserverEntry[] { return []; }
readonly root = null;
readonly rootMargin = '300px 0px 300px 0px';
readonly thresholds = [0];
emit(isIntersecting: boolean): void {
this.callback([{ isIntersecting } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
}
}
beforeEach(() => {
IntersectionObserverFake.instances = [];
vi.stubGlobal('IntersectionObserver', IntersectionObserverFake);
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined);
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const mountMedia = (presentation: MediaPresentation, allowAutomaticVideo = false) => mount(ProjectMedia, {
props: { presentation, allowAutomaticVideo },
global: { provide: { [productionInputsKey as symbol]: injectedInputs } },
});
const mountGallery = (media: readonly MediaPresentation[], layout: 'single' | 'portrait-strip' | 'chart-pair') => mount(ProjectMediaGallery, {
props: { media, layout },
global: { provide: { [productionInputsKey as symbol]: injectedInputs } },
});
describe('ProjectMedia', () => {
it('resolves only manifest assets and keeps contextual wording in content', () => {
const wrapper = mountMedia({
kind: 'image',
assetId: 'project-image',
alt: 'A fictional application schedule on a phone.',
caption: 'A sanitized mobile workflow.',
longDescription: 'The interface groups the daily schedule and stock status.',
});
const image = wrapper.get('img');
expect(image.attributes()).toMatchObject({
src: '/assets/project-image.webp',
alt: 'A fictional application schedule on a phone.',
width: '1200',
height: '800',
loading: 'lazy',
});
expect(wrapper.get('figcaption').text()).toBe('A sanitized mobile workflow.');
expect(wrapper.get('[data-testid="media-long-description"]').text()).toContain('daily schedule');
expect(() => mountMedia({ kind: 'image', assetId: 'not-in-manifest', alt: 'Unknown asset.' }))
.toThrow(/unknown asset id/i);
});
it('renders chart variables, trend, conclusion, and exact-value table as visible text', () => {
const wrapper = mountMedia({
kind: 'chart',
assetId: 'project-chart',
alt: 'A line chart of polarization over simulation steps.',
caption: 'Polarization rises during this modeled run.',
chart: {
variables: 'Horizontal: simulation step. Vertical: polarization index.',
trend: 'The index rises sharply, then increases gradually.',
conclusion: 'This run ends more polarized than it began.',
table: {
caption: 'Selected modeled values',
columns: ['Step', 'Polarization index'],
rows: [['0', '0.765'], ['14', '0.932']],
},
},
});
expect(wrapper.get('figcaption').text()).toContain('Polarization rises');
expect(wrapper.get('[data-testid="chart-variables"]').text()).toContain('simulation step');
expect(wrapper.get('[data-testid="chart-trend"]').text()).toContain('rises sharply');
expect(wrapper.get('[data-testid="chart-conclusion"]').text()).toContain('more polarized');
expect(wrapper.get('table caption').text()).toBe('Selected modeled values');
expect(wrapper.findAll('tbody tr')).toHaveLength(2);
});
it('keeps video sources detached until explicit Play when automatic media is blocked', async () => {
const wrapper = mountMedia({
kind: 'video',
assetId: 'project-loop',
fallbackAssetId: 'project-loop-mp4',
posterAssetId: 'project-loop-poster',
alt: 'Gameplay from a private prototype.',
caption: 'A short gameplay-only excerpt.',
longDescription: 'The player navigates a compact prototype environment.',
});
const video = wrapper.get('video');
expect(video.attributes('preload')).toBe('none');
expect(video.attributes()).not.toHaveProperty('controls');
expect(video.findAll('source')).toHaveLength(0);
expect(wrapper.get('button').text()).toBe('Play video');
IntersectionObserverFake.instances[0]!.emit(true);
await nextTick();
expect(video.findAll('source')).toHaveLength(0);
await wrapper.get('button').trigger('click');
await nextTick();
expect(video.findAll('source').map((source) => source.attributes('src'))).toEqual([
'/assets/project-loop.webm',
'/assets/project-loop.mp4',
]);
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledOnce();
});
it('attaches inside 300 CSS pixels 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');
observer.emit(true);
await nextTick();
expect(wrapper.findAll('video source')).toHaveLength(1);
const video = wrapper.get('video').element as HTMLVideoElement;
video.currentTime = 2.25;
observer.emit(false);
await nextTick();
expect(wrapper.findAll('video source')).toHaveLength(0);
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled();
video.currentTime = 0;
observer.emit(true);
await nextTick();
await wrapper.get('video').trigger('loadedmetadata');
expect(video.currentTime).toBe(2.25);
});
it('reports playback failure without exposing native controls', async () => {
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValueOnce(new Error('blocked'));
const wrapper = mount(AccessibleVideo, {
props: {
sources: [{ src: '/assets/project-loop.webm', type: 'video/webm' }],
poster: '/assets/project-loop-poster.webp',
width: 1280,
height: 720,
caption: 'Gameplay.',
description: 'A gameplay loop.',
allowAutomatic: false,
},
});
IntersectionObserverFake.instances[0]!.emit(true);
await wrapper.get('button').trigger('click');
await nextTick();
expect(wrapper.get('button').text()).toBe('Video unavailable');
expect(wrapper.get('[data-testid="video-status"]').text()).toContain('could not be played');
expect(wrapper.get('video').attributes()).not.toHaveProperty('controls');
});
});
describe('ProjectMediaGallery', () => {
it('keeps three portrait images lazy, contextual, and in their supplied order', () => {
const wrapper = mountGallery([
{ kind: 'image', assetId: 'project-image', alt: 'Synthetic profile screen.', caption: 'Fictional demo profile.' },
{ kind: 'image', assetId: 'project-chart', alt: 'Synthetic chart screen.', caption: 'Fictional demo chart.' },
{ kind: 'image', assetId: 'project-portrait', alt: 'Synthetic portrait screen.', caption: 'Fictional demo profile settings.' },
], 'portrait-strip');
expect(wrapper.attributes('data-media-layout')).toBe('portrait-strip');
expect(wrapper.findAll('.project-media-gallery__item')).toHaveLength(3);
expect(wrapper.findAll('figure')).toHaveLength(3);
expect(wrapper.findAll('img').map((image) => image.attributes('loading'))).toEqual(['lazy', 'lazy', 'lazy']);
expect(wrapper.text()).toContain('Fictional demo profile');
});
it('preserves image and chart media semantics across single and chart-pair layouts', () => {
const single = mountGallery([
{ kind: 'image', assetId: 'project-image', alt: 'Synthetic application screen.', caption: 'Fictional demo profile.' },
], 'single');
const chartPair = mountGallery([
{
kind: 'chart', assetId: 'project-chart', alt: 'Synthetic trend chart.', caption: 'Fictional demo chart.',
chart: {
variables: 'Horizontal: month. Vertical: completion rate.',
trend: 'The modeled rate rises.',
conclusion: 'The modeled run finishes above its starting point.',
},
},
{ kind: 'image', assetId: 'project-image', alt: 'Synthetic supporting screen.', caption: 'Fictional supporting screen.' },
], 'chart-pair');
expect(single.attributes('data-media-layout')).toBe('single');
expect(single.get('img').attributes('loading')).toBe('lazy');
expect(single.get('figcaption').text()).toBe('Fictional demo profile.');
expect(chartPair.attributes('data-media-layout')).toBe('chart-pair');
expect(chartPair.findAll('figure')).toHaveLength(2);
expect(chartPair.get('[data-testid="chart-variables"]').text()).toContain('completion rate');
expect(chartPair.get('img').attributes('loading')).toBe('lazy');
});
it('renders no gallery container for an empty media list', () => {
const wrapper = mountGallery([], 'single');
expect(wrapper.find('.project-media-gallery').exists()).toBe(false);
});
});
describe('useLazyMedia', () => {
it('fails closed without an observer and supports an explicit request', async () => {
vi.stubGlobal('IntersectionObserver', undefined);
const target = ref<Element | null>(document.createElement('div'));
const wrapper = mount({
setup() {
return useLazyMedia(target, ref(true));
},
template: '<div />',
});
expect((wrapper.vm as unknown as { shouldAttach: boolean }).shouldAttach).toBe(false);
(wrapper.vm as unknown as { requestExplicit: () => void }).requestExplicit();
await nextTick();
expect((wrapper.vm as unknown as { shouldAttach: boolean }).shouldAttach).toBe(true);
});
});
describe('media content schema', () => {
it('requires content-owned descriptions and exact project asset closure', () => {
const raw = JSON.parse(JSON.stringify({
identity: {
name: 'Luca Pîslaru', role: 'System Administrator & Full-Stack Developer', valueProposition: 'Value.',
location: { city: 'Bucharest', country: 'Romania', countryCode: 'RO' }, availability: 'Available remotely.',
},
navigation: ['start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact']
.map((id) => ({ id, label: id })),
profile: { introduction: 'Profile.', screeningFacts: [] },
experience: [{ id: 'role', role: 'Role', organization: 'Org', dates: 'Jan 2024 – Present', highlights: ['One.', 'Two.'] }],
systemsOperations: { id: 'systems', title: 'Professional systems operations', summary: 'Summary.', evidence: ['Evidence.'] },
infrastructure: {
id: 'infra', title: 'Secure self-hosted platform and lifecycle', summary: 'Summary.',
bands: ['one', 'two', 'three', 'four'].map((id) => ({ id, title: id, summary: 'Summary.' })),
featuredOperation: { title: 'Migration', context: 'Context.', actions: ['Prepare.', 'Move.', 'Validate.'], outcome: 'Completed.' },
},
projects: {
selected: [
{ id: 'private', title: 'Private self-hosted medication tracker', visibility: 'private', maturity: 'active', summary: 'S.', problem: 'P.', contribution: 'C.', technicalDecisions: ['D.'], outcome: 'O.', assetIds: ['project-image'], media: [{ kind: 'image', assetId: 'project-image', alt: 'Contextual alternative.' }] },
{ id: 'footy', title: 'Footy Tracker', visibility: 'public', maturity: 'work-in-progress', summary: 'S.', problem: 'P.', contribution: 'C.', technicalDecisions: ['D.'], outcome: 'O.', assetIds: [], media: [] },
{ id: 'research', title: 'Opinion Dynamics Simulation', visibility: 'public', maturity: 'experimental', summary: 'S.', problem: 'P.', contribution: 'C.', technicalDecisions: ['D.'], outcome: 'O.', assetIds: [], media: [] },
],
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.' },
metadata: { title: 'Title.', description: 'Description.' },
sceneDescription: 'Scene.',
}));
expect(parsePortfolioContent(raw).projects.selected[0]!.media?.[0]!.alt).toBe('Contextual alternative.');
raw.projects.selected[0].media[0].manifestCaption = 'Forbidden authority.';
expect(() => parsePortfolioContent(raw)).toThrow(/unrecognized/i);
delete raw.projects.selected[0].media[0].manifestCaption;
raw.projects.selected[0].assetIds = [];
expect(() => parsePortfolioContent(raw)).toThrow(/asset closure/i);
});
it('rejects contextual copy on manifest records', () => {
const raw = structuredClone(manifest) as unknown as { assets: Array<Record<string, unknown>> };
raw.assets[0]!.alt = 'Forbidden manifest alternative.';
expect(() => parsePublicAssetManifest(raw)).toThrow(/unrecognized/i);
});
});