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(document.createElement('div')); const wrapper = mount({ setup() { return useLazyMedia(target, ref(true)); }, template: '
', }); 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> }; raw.assets[0]!.alt = 'Forbidden manifest alternative.'; expect(() => parsePublicAssetManifest(raw)).toThrow(/unrecognized/i); }); });