forked from EXT/VR180-Web-Player
Added tests in
This commit is contained in:
@@ -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": {
|
||||
|
||||
195
tests/media-controller.test.mjs
Normal file
195
tests/media-controller.test.mjs
Normal file
@@ -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']);
|
||||
});
|
||||
106
tests/projection.test.mjs
Normal file
106
tests/projection.test.mjs
Normal file
@@ -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]);
|
||||
});
|
||||
20
tests/time.test.mjs
Normal file
20
tests/time.test.mjs
Normal file
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user