feat: present cleared accessible project media
This commit is contained in:
274
tests/unit/ProjectMedia.test.ts
Normal file
274
tests/unit/ProjectMedia.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
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 { 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-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 } },
|
||||
});
|
||||
|
||||
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('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.' },
|
||||
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.' })),
|
||||
},
|
||||
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: { 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user