From a254dca51832e5048df7f38c8077e02509d39d12 Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:57:23 +1000 Subject: [PATCH] Added tests in --- package.json | 1 + tests/media-controller.test.mjs | 195 ++++++++++++++++++++++++++++++++ tests/projection.test.mjs | 106 +++++++++++++++++ tests/time.test.mjs | 20 ++++ 4 files changed, 322 insertions(+) create mode 100644 tests/media-controller.test.mjs create mode 100644 tests/projection.test.mjs create mode 100644 tests/time.test.mjs diff --git a/package.json b/package.json index 82ae332..97bbd2d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "npm run build && vite --host 127.0.0.1", "build": "tsc", "check": "tsc --noEmit", + "test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs", "preview": "npm run build && vite preview --host 127.0.0.1" }, "devDependencies": { diff --git a/tests/media-controller.test.mjs b/tests/media-controller.test.mjs new file mode 100644 index 0000000..6637c04 --- /dev/null +++ b/tests/media-controller.test.mjs @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { MediaController } from '../vr180player/media/media-controller.js'; + +function createClassList() { + const classes = new Set(); + return { + add: (className) => classes.add(className), + contains: (className) => classes.has(className), + remove: (className) => classes.delete(className) + }; +} + +function createVideo(overrides = {}) { + const video = { + HAVE_ENOUGH_DATA: 4, + controls: true, + currentSrc: 'video.mp4', + currentTime: 20, + duration: 120, + ended: false, + loadCount: 0, + muted: false, + pauseCount: 0, + paused: true, + playCount: 0, + readyState: 4, + volume: 1, + load() { + this.loadCount += 1; + }, + pause() { + this.pauseCount += 1; + this.paused = true; + }, + play() { + this.playCount += 1; + this.paused = false; + this.ended = false; + return Promise.resolve(); + }, + ...overrides + }; + + return video; +} + +function createController({ + is2DModeActive = () => false, + on2DPlaybackResume = () => {}, + playButton = { classList: createClassList(), disabled: true }, + video = createVideo() +} = {}) { + return { + controller: new MediaController({ + is2DModeActive, + on2DPlaybackResume, + playButton, + video + }), + playButton, + video + }; +} + +test('MediaController clamps skip controls to the video bounds', () => { + const { controller, video } = createController(); + + controller.forward(); + assert.equal(video.currentTime, 35); + + video.currentTime = 118; + controller.forward(); + assert.equal(video.currentTime, 120); + + controller.rewind(); + assert.equal(video.currentTime, 105); + + video.currentTime = 4; + controller.rewind(); + assert.equal(video.currentTime, 0); +}); + +test('MediaController seeks by progress and ignores videos without finite duration', () => { + const { controller, video } = createController(); + + controller.seekToProgress(0.25); + assert.equal(video.currentTime, 30); + + video.duration = Number.POSITIVE_INFINITY; + controller.seekToProgress(0.75); + assert.equal(video.currentTime, 30); +}); + +test('MediaController toggles mute and native controls', () => { + const { controller, video } = createController(); + + controller.toggleMute(); + assert.equal(video.muted, true); + + controller.toggleMute(); + assert.equal(video.muted, false); + + video.controls = false; + controller.enableNativeControls(); + assert.equal(video.controls, true); +}); + +test('MediaController resets video and play button to poster state', () => { + const playButton = { classList: createClassList(), disabled: true }; + playButton.classList.add('hidden'); + const { controller, video } = createController({ playButton }); + + controller.resetToOriginalState(); + + assert.equal(video.pauseCount, 1); + assert.equal(video.currentTime, 0); + assert.equal(video.controls, false); + assert.equal(video.loadCount, 1); + assert.equal(playButton.classList.contains('hidden'), false); + assert.equal(playButton.disabled, false); +}); + +test('MediaController restarts ended video before playing in 2D mode', async () => { + let resumed = false; + const { controller, video } = createController({ + is2DModeActive: () => true, + on2DPlaybackResume: () => { + resumed = true; + }, + video: createVideo({ currentTime: 120, ended: true, paused: true }) + }); + + controller.togglePlayPause(); + await Promise.resolve(); + + assert.equal(video.currentTime, 0); + assert.equal(video.playCount, 1); + assert.equal(resumed, true); +}); + +test('MediaController pauses when toggling playback while already playing', () => { + const { controller, video } = createController({ + video: createVideo({ paused: false }) + }); + + controller.togglePlayPause(); + + assert.equal(video.pauseCount, 1); + assert.equal(video.paused, true); +}); + +test('MediaController dispatches ended behavior for VR, 2D, and idle modes', async () => { + const vrCalls = []; + const { controller } = createController({ + video: createVideo({ paused: false }) + }); + + controller.handleEnded({ + cleanupFailedVrExit: () => vrCalls.push('cleanup'), + exitVr: () => { + vrCalls.push('exit'); + return Promise.resolve(); + }, + isIn2DMode: () => false, + isInVr: () => true, + on2DEnded: () => vrCalls.push('2d'), + resetToOriginalState: () => vrCalls.push('reset') + }); + await Promise.resolve(); + assert.deepEqual(vrCalls, ['exit']); + + const twoDCalls = []; + controller.handleEnded({ + cleanupFailedVrExit: () => twoDCalls.push('cleanup'), + exitVr: () => Promise.resolve(), + isIn2DMode: () => true, + isInVr: () => false, + on2DEnded: () => twoDCalls.push('2d'), + resetToOriginalState: () => twoDCalls.push('reset') + }); + assert.deepEqual(twoDCalls, ['2d']); + + const idleCalls = []; + controller.handleEnded({ + cleanupFailedVrExit: () => idleCalls.push('cleanup'), + exitVr: () => Promise.resolve(), + isIn2DMode: () => false, + isInVr: () => false, + on2DEnded: () => idleCalls.push('2d'), + resetToOriginalState: () => idleCalls.push('reset') + }); + assert.deepEqual(idleCalls, ['reset']); +}); diff --git a/tests/projection.test.mjs b/tests/projection.test.mjs new file mode 100644 index 0000000..1cb6deb --- /dev/null +++ b/tests/projection.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + applySbsTextureWindow, + hideContentMeshes, + isLeftEyeCamera, + positionPlaneForPresentation, + showActiveContentMesh +} from '../vr180player/rendering/projection.js'; + +function createMaterial() { + return { + map: { + offset: { x: 99, y: 99 }, + repeat: { x: 99, y: 99 } + } + }; +} + +function createRenderer({ isPresenting = false, xrCamera = null } = {}) { + return { + xr: { + getCamera: () => xrCamera, + isPresenting + } + }; +} + +function createCamera(x, projectionOffset = 0) { + return { + matrixWorldInverse: { elements: new Array(16).fill(0).with(12, x) }, + projectionMatrix: { elements: new Array(16).fill(0).with(8, projectionOffset) } + }; +} + +test('applySbsTextureWindow uses left eye only in non-XR 2D fallback', () => { + const material = createMaterial(); + + applySbsTextureWindow(createRenderer(), createCamera(0), material, true); + + assert.equal(material.map.offset.x, 0); + assert.equal(material.map.repeat.x, 0.5); + assert.equal(material.map.offset.y, 0); + assert.equal(material.map.repeat.y, 1); +}); + +test('applySbsTextureWindow keeps the full texture outside XR when not in 2D fallback', () => { + const material = createMaterial(); + + applySbsTextureWindow(createRenderer(), createCamera(0), material, false); + + assert.equal(material.map.offset.x, 0); + assert.equal(material.map.repeat.x, 1); + assert.equal(material.map.offset.y, 0); + assert.equal(material.map.repeat.y, 1); +}); + +test('applySbsTextureWindow selects the right SBS half for the right XR eye', () => { + const leftCamera = createCamera(-0.03); + const rightCamera = createCamera(0.03); + const renderer = createRenderer({ + isPresenting: true, + xrCamera: { cameras: [leftCamera, rightCamera] } + }); + const material = createMaterial(); + + applySbsTextureWindow(renderer, rightCamera, material, false); + + assert.equal(material.map.offset.x, 0.5); + assert.equal(material.map.repeat.x, 0.5); +}); + +test('isLeftEyeCamera falls back to projection matrix offset when XR cameras are unavailable', () => { + assert.equal(isLeftEyeCamera(createRenderer({ isPresenting: true }), createCamera(0, -0.1)), true); + assert.equal(isLeftEyeCamera(createRenderer({ isPresenting: true }), createCamera(0, 0.1)), false); +}); + +test('showActiveContentMesh hides inactive meshes before showing the active mesh', () => { + const vr180Mesh = { visible: true }; + const planeMesh = { visible: true }; + + showActiveContentMesh(vr180Mesh, planeMesh, planeMesh); + + assert.equal(vr180Mesh.visible, false); + assert.equal(planeMesh.visible, true); + + hideContentMeshes(vr180Mesh, planeMesh); + assert.equal(vr180Mesh.visible, false); + assert.equal(planeMesh.visible, false); +}); + +test('positionPlaneForPresentation uses the fallback camera depth in 2D plane mode', () => { + const calls = []; + const planeMesh = { + position: { + set: (...args) => calls.push(args) + } + }; + + positionPlaneForPresentation(planeMesh, { position: { z: 0.1 } }, true, 3, 1.2); + positionPlaneForPresentation(planeMesh, { position: { z: 0.1 } }, false, 3, 1.2); + + assert.deepEqual(calls[0], [0, 1.6, -1.0999999999999999]); + assert.deepEqual(calls[1], [0, 1.6, -3]); +}); diff --git a/tests/time.test.mjs b/tests/time.test.mjs new file mode 100644 index 0000000..116445c --- /dev/null +++ b/tests/time.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { formatTime } from '../vr180player/utils/time.js'; + +test('formatTime formats minute-length durations', () => { + assert.equal(formatTime(0), '00:00'); + assert.equal(formatTime(65), '01:05'); + assert.equal(formatTime(599), '09:59'); +}); + +test('formatTime formats hour-length durations', () => { + assert.equal(formatTime(3600), '01:00:00'); + assert.equal(formatTime(3661), '01:01:01'); +}); + +test('formatTime handles invalid durations defensively', () => { + assert.equal(formatTime(Number.NaN), '00:00:00'); + assert.equal(formatTime(Number.POSITIVE_INFINITY), '00:00:00'); +});