feat: present cleared accessible project media

This commit is contained in:
2026-09-02 06:50:00 +03:00
parent dfd56f0a48
commit f953905a4b
12 changed files with 832 additions and 4 deletions

View 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);
});
});