367 lines
17 KiB
TypeScript
367 lines
17 KiB
TypeScript
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';
|
||
import approvedContentJson from '@/../content/public/portfolio-content.json';
|
||
import approvedManifestJson from '@/../content/public/asset-manifest.json';
|
||
|
||
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('renders every approved project still with intrinsic, lazy, contextual semantics', () => {
|
||
const content = parsePortfolioContent(structuredClone(approvedContentJson));
|
||
const approvedManifest = parsePublicAssetManifest(structuredClone(approvedManifestJson));
|
||
const approvedInputs = { manifest: approvedManifest, content, site: {} } as unknown as ProductionInputs;
|
||
const projects = [...content.projects.selected, ...content.projects.additional];
|
||
const mediaProjects = projects.filter((project) => project.media !== undefined);
|
||
|
||
expect(mediaProjects.map(({ id }) => id)).toEqual([
|
||
'private-medication-tracker',
|
||
'opinion-dynamics-simulation',
|
||
'currency-converter',
|
||
]);
|
||
|
||
const wrappers = mediaProjects.map((project) => mount(ProjectMediaGallery, {
|
||
props: { media: project.media ?? [], layout: project.mediaLayout ?? 'single' },
|
||
global: { provide: { [productionInputsKey as symbol]: approvedInputs } },
|
||
}));
|
||
const images = wrappers.flatMap((wrapper) => wrapper.findAll('img'));
|
||
|
||
expect(images).toHaveLength(6);
|
||
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);
|
||
expect(wrappers[0]!.text()).toMatch(/fictional demo state|private self-hosted system/iu);
|
||
expect(wrappers[1]!.findAll('[data-testid="chart-variables"]')).toHaveLength(2);
|
||
expect(wrappers[1]!.findAll('table')).toHaveLength(1);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|