turn.js/tests/unit/turn.unit.test.js
2026-06-08 23:22:16 +02:00

790 lines
22 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const require = createRequire(import.meta.url);
const __dirname = dirname(fileURLToPath(import.meta.url));
const jqueryDist = dirname(require.resolve('jquery'));
const jquerySource = readFileSync(resolve(jqueryDist, 'jquery.js'), 'utf8');
const turnSource = readFileSync(resolve(__dirname, '../../turn.js'), 'utf8');
function createFixture(pageCount = 4, windowOptions = {}) {
const pages = Array.from({ length: pageCount }, (_, index) => (
`<div class="page">Page ${index + 1}</div>`
)).join('');
const dom = new JSDOM(`<!doctype html><html><body><div id="book">${pages}</div></body></html>`, {
pretendToBeVisual: true,
runScripts: 'outside-only',
url: 'http://localhost/'
});
Object.entries(windowOptions).forEach(([key, value]) => {
if (value === undefined) {
delete dom.window[key];
return;
}
Object.defineProperty(dom.window, key, {
value,
configurable: true,
writable: true
});
});
dom.window.eval(jquerySource);
const $ = dom.window.jQuery;
dom.window.eval(turnSource);
return { window: dom.window, document: dom.window.document, $ };
}
function sortedKeys(object) {
return Object.keys(object).sort();
}
function expectTrackedWrappersToBeConnected($book) {
const state = $book.data();
const pageWrappers = Object.values(state.pageWrap || {}).map($wrapper => $wrapper[0]);
const flipStates = Object.values(state.pages || {}).map($page => $page.data('f'));
expect($book.find('.turn-page-wrapper').get()).toEqual(expect.arrayContaining(pageWrappers));
for (const wrapper of pageWrappers) {
expect(wrapper.isConnected).toBe(true);
}
for (const flipState of flipStates) {
expect(flipState.wrapper[0].isConnected).toBe(true);
expect(flipState.fwrapper[0].isConnected).toBe(true);
expect(flipState.fpage[0].isConnected).toBe(true);
}
}
function captureThrownValue(callback) {
try {
callback();
} catch (error) {
return error;
}
}
describe('turn.js jQuery plugin', () => {
let fixture;
beforeEach(() => {
fixture = createFixture();
});
afterEach(() => {
fixture.window.close();
vi.restoreAllMocks();
});
it('registers the turn, flip and transform plugins', () => {
const { $ } = fixture;
expect($.fn.turn).toBeTypeOf('function');
expect($.fn.flip).toBeTypeOf('function');
expect($.fn.transform).toBeTypeOf('function');
});
it('supports the documented public turn API methods', () => {
const { $ } = fixture;
const $book = $('#book');
$book.turn('init', {
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
expect($book.turn('page')).toBe(1);
expect($book.turn('pages')).toBe(4);
expect($book.turn('size')).toEqual({ width: 600, height: 400 });
$book.turn('page', 2);
expect($book.turn('page')).toBe(2);
$book.turn('addPage', $('<div class="page">Page 5</div>'), 5);
expect($book.turn('pages')).toBe(5);
$book.turn('removePage', 5);
expect($book.turn('pages')).toBe(4);
$book.turn('pages', 4);
expect($book.turn('pages')).toBe(4);
$book.turn('size', 320, 240);
expect($book.turn('size')).toEqual({ width: 320, height: 240 });
$book.turn('display', 'single');
expect($book.turn('display')).toBe('single');
$book.turn('disable', true);
expect($book.data('disabled')).toBe(true);
$book.turn('disable', false);
expect($book.data('disabled')).toBe(false);
expect(captureThrownValue(() => $book.turn('next'))).toBeUndefined();
$book.turn('destroy');
expect($book.data('opts')).toBeUndefined();
fixture.window.close();
fixture = createFixture();
const $bookForPrevious = fixture.$('#book');
$bookForPrevious.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false,
page: 2
});
expect(captureThrownValue(() => $bookForPrevious.turn('previous'))).toBeUndefined();
$bookForPrevious.turn('destroy');
});
it('rejects invalid and private turn method calls', () => {
const { $ } = fixture;
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
expect(captureThrownValue(() => $book.turn('unknownMethod'))).toBe('unknownMethod is an invalid value');
expect(captureThrownValue(() => $book.turn('_view'))).toBe('_view is an invalid value');
expect(captureThrownValue(() => $book.turn('display', 'spread')).message).toBe('"spread" is not a value for display');
});
it('calculates fold geometry without DOM for every corner', () => {
fixture.window.close();
fixture = createFixture(4, {
__TURNJS_TEST_HOOKS__: {}
});
const calculateFoldGeometry = fixture.window.__TURNJS_TEST_HOOKS__.calculateFoldGeometry;
const base = {
width: 300,
height: 400,
wrapperHeight: 500,
frontGradient: true,
backGradient: true
};
const cases = [
{
point: { corner: 'tl', x: 90, y: 120 },
origin: { x: 0, y: 0 },
endingPoint: { x: 600, y: 0 },
top: true,
left: true
},
{
point: { corner: 'tr', x: 210, y: 120 },
origin: { x: 300, y: 0 },
endingPoint: { x: -300, y: 0 },
top: true,
left: false
},
{
point: { corner: 'bl', x: 90, y: 280 },
origin: { x: 0, y: 400 },
endingPoint: { x: 600, y: 400 },
top: false,
left: true
},
{
point: { corner: 'br', x: 210, y: 280 },
origin: { x: 300, y: 400 },
endingPoint: { x: -300, y: 400 },
top: false,
left: false
}
];
expect(calculateFoldGeometry).toBeTypeOf('function');
for (const item of cases) {
const originalPoint = { ...item.point };
const geometry = calculateFoldGeometry({ ...base, ...item });
expect(item.point).toEqual(originalPoint);
expect(geometry.point.corner).toBe(item.point.corner);
expect(geometry.top).toBe(item.top);
expect(geometry.left).toBe(item.left);
expect(Number.isFinite(geometry.alpha)).toBe(true);
expect(Number.isFinite(geometry.angle)).toBe(true);
expect(Number.isFinite(geometry.px)).toBe(true);
expect(Number.isFinite(geometry.tr.x)).toBe(true);
expect(Number.isFinite(geometry.tr.y)).toBe(true);
expect(Number.isFinite(geometry.move.x)).toBe(true);
expect(Number.isFinite(geometry.move.y)).toBe(true);
expect(Number.isFinite(geometry.df.x)).toBe(true);
expect(Number.isFinite(geometry.df.y)).toBe(true);
expect(Number.isFinite(geometry.gradient.opacity)).toBe(true);
expect(Number.isFinite(geometry.gradient.size)).toBe(true);
expect(geometry.gradient.endPointA).toBeDefined();
expect(geometry.gradient.endPointB).toBeDefined();
if (item.left)
expect(geometry.point.x).toBeGreaterThanOrEqual(1);
else
expect(geometry.point.x).toBeLessThanOrEqual(base.width - 1);
}
});
it('builds normalized linear gradients without legacy syntax or white highlights', () => {
fixture.window.close();
fixture = createFixture(4, {
__TURNJS_TEST_HOOKS__: {}
});
const buildLinearGradient = fixture.window.__TURNJS_TEST_HOOKS__.buildLinearGradient;
const gradient = buildLinearGradient(
{ x: 0, y: 0 },
{ x: -10, y: 0 },
[
[-1, 'rgba(0,0,0,0)'],
[0.5, 'rgba(0,0,0,0.12)'],
[2, 'rgba(0,0,0,0)']
],
3
);
expect(gradient).toMatch(/^linear-gradient\(180deg,/);
expect(gradient).not.toContain('-webkit-gradient');
expect(gradient).not.toContain('255, 255, 255');
expect([...gradient.matchAll(/([0-9.]+)%/g)].map(match => Number(match[1]))).toEqual([
0,
50,
100
]);
});
it('normalizes fold gradient opacity for tiny and large folds', () => {
fixture.window.close();
fixture = createFixture(4, {
__TURNJS_TEST_HOOKS__: {}
});
const calculateFoldGeometry = fixture.window.__TURNJS_TEST_HOOKS__.calculateFoldGeometry;
const base = {
point: { corner: 'tl', x: 90, y: 120 },
origin: { x: 0, y: 0 },
width: 300,
height: 400,
wrapperHeight: 500,
frontGradient: true,
backGradient: true
};
const tinyFold = calculateFoldGeometry({
...base,
endingPoint: { x: 90, y: 120 }
});
const largeFold = calculateFoldGeometry({
...base,
endingPoint: { x: 600, y: 0 }
});
expect(tinyFold.gradient.opacity).toBe(0);
expect(largeFold.gradient.opacity).toBe(1);
});
it('does not create gradient shadow layers when gradients are disabled', () => {
const { $ } = fixture;
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
const pageState = $book.data('pages')[1].data('f');
expect(pageState.opts.frontGradient).toBe(false);
expect(pageState.opts.backGradient).toBe(false);
expect(pageState.ashadow).toBeUndefined();
expect(pageState.bshadow).toBeUndefined();
expect($book.find('[style*="linear-gradient"]').length).toBe(0);
});
it('initializes a book and exposes page, view, pages and size state', () => {
const { $ } = fixture;
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
expect($book.turn('page')).toBe(1);
expect($book.turn('pages')).toBe(4);
expect($book.turn('view')).toEqual([0, 1]);
expect($book.turn('size')).toEqual({ width: 600, height: 400 });
expect($book.find('.turn-page-wrapper').length).toBeGreaterThan(0);
});
it('uses CSS classes instead of inline styles for static wrapper styling', () => {
const { document, $ } = fixture;
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: true,
acceleration: false
});
const state = $book.data();
const pageWrapper = state.pageWrap[1][0];
const flipState = state.pages[1].data('f');
expect(document.getElementById('turn-js-base-css')).toBeTruthy();
expect(pageWrapper.classList.contains('turn-page-wrapper')).toBe(true);
expect(pageWrapper.style.position).toBe('');
expect(pageWrapper.style.overflow).toBe('');
expect(flipState.wrapper[0].classList.contains('turn-flip-wrapper')).toBe(true);
expect(flipState.wrapper[0].style.position).toBe('');
expect(flipState.wrapper[0].style.overflow).toBe('');
expect(flipState.fwrapper[0].classList.contains('turn-fold-wrapper')).toBe(true);
expect(flipState.fwrapper[0].style.position).toBe('');
expect(flipState.fwrapper[0].style.overflow).toBe('');
expect(flipState.fparent[0].classList.contains('turn-fold-parent')).toBe(true);
expect(flipState.fparent[0].style.pointerEvents).toBe('');
});
it('supports initial page selection without animation', () => {
const { $ } = fixture;
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false,
page: 2
});
expect($book.turn('page')).toBe(2);
expect($book.turn('view')).toEqual([2, 3]);
});
it('keeps documented book and flip state after initialization', () => {
const { $ } = fixture;
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
const bookState = $book.data();
expect(sortedKeys(bookState)).toEqual([
'display',
'done',
'eventHandlers',
'fparent',
'opts',
'page',
'pageDefaults',
'pageMv',
'pageObjs',
'pagePlace',
'pageWrap',
'pages',
'totalPages',
'turnOriginal'
]);
expect(bookState.opts).toMatchObject({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
expect(bookState.turnOriginal).toEqual({
style: undefined,
class: undefined
});
expect(Object.keys(bookState.pageObjs)).toEqual(['1', '2', '3', '4']);
expect(Object.keys(bookState.pageDefaults)).toEqual(['1', '2', '3', '4']);
expect(Object.keys(bookState.pageWrap)).toEqual(['1', '2', '3', '4']);
expect(Object.keys(bookState.pagePlace)).toEqual(['1', '2', '3', '4']);
expect(Object.keys(bookState.pages)).toEqual(['1']);
expect(bookState.pageMv).toEqual([]);
expect(bookState.totalPages).toBe(4);
expect(bookState.page).toBe(1);
const pageState = bookState.pages[1].data();
expect(pageState.f).toBeDefined();
expect(pageState.f.opts).toMatchObject({
page: 1,
next: 2,
turn: $book,
duration: 600,
acceleration: false,
corners: 'forward',
backGradient: false,
frontGradient: false
});
expect(pageState.f.parent[0]).toBe(bookState.pageWrap[1][0]);
expect(pageState.f.wrapper).toBeDefined();
expect(pageState.f.fwrapper).toBeDefined();
expect(pageState.f.fpage).toBeDefined();
});
it('updates moving page state during next and previous turns', () => {
fixture.window.close();
const frameCallbacks = [];
const requestAnimationFrame = vi.fn(callback => {
frameCallbacks.push(callback);
return frameCallbacks.length;
});
fixture = createFixture(6, {
requestAnimationFrame,
cancelAnimationFrame: vi.fn()
});
const { $ } = fixture;
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false,
duration: 32
});
$book.turn('next');
expect($book.turn('page')).toBe(2);
expect($book.turn('view')).toEqual([2, 3]);
expect($book.data('tpage')).toBe(2);
expect($book.data('pageMv')).toEqual([1]);
while (frameCallbacks.length) {
frameCallbacks.shift()(1000);
}
expect($book.data('tpage')).toBeUndefined();
expect($book.data('pageMv')).toEqual([]);
$book.turn('previous');
expect($book.turn('page')).toBe(1);
expect($book.turn('view')).toEqual([0, 1]);
expect($book.data('tpage')).toBe(1);
expect($book.data('pageMv')).toEqual([2]);
while (frameCallbacks.length) {
frameCallbacks.shift()(2000);
}
expect($book.data('tpage')).toBeUndefined();
expect($book.data('pageMv')).toEqual([]);
});
it('can add and remove pages dynamically', () => {
const { $ } = fixture;
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
$book.turn('addPage', $('<div class="page">Page 5</div>'), 5);
expect($book.turn('pages')).toBe(5);
expect($book.turn('hasPage', 5)).toBe(true);
$book.turn('removePage', 5);
expect($book.turn('pages')).toBe(4);
expect($book.turn('hasPage', 5)).toBe(false);
});
it('does not leave tracked wrappers orphaned after removePage', () => {
fixture.window.close();
fixture = createFixture(6);
const { $ } = fixture;
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
$book.turn('removePage', 3);
expect($book.turn('pages')).toBe(5);
expect($book.turn('hasPage', 6)).toBe(false);
expectTrackedWrappersToBeConnected($book);
expect($book.find('.turn-page-wrapper').length).toBe(Object.keys($book.data('pageWrap')).length);
});
it('cleans the temporary single-display page when returning to double display', () => {
const { $ } = fixture;
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
$book.turn('display', 'single');
expect($book.data('pageObjs')[0]).toBeDefined();
expect($book.find('.p-temporal').length).toBe(1);
expectTrackedWrappersToBeConnected($book);
$book.turn('display', 'double');
expect($book.data('pageObjs')[0]).toBeUndefined();
expect($book.find('.p-temporal').length).toBe(0);
expectTrackedWrappersToBeConnected($book);
});
it('destroys a book, restores pages and removes turn.js state', () => {
const { $ } = fixture;
const $book = $('#book');
$book.data('externalValue', 'keep-me');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
expect($book.find('.turn-page-wrapper').length).toBeGreaterThan(0);
expect($book.children('.page').length).toBe(0);
const fparent = $book.data('fparent');
$book.turn('destroy');
expect(fparent[0].isConnected).toBe(false);
expect($book.data('externalValue')).toBe('keep-me');
expect($book.data('opts')).toBeUndefined();
expect($book.data('pages')).toBeUndefined();
expect($book.find('.turn-page-wrapper').length).toBe(0);
expect($book.find('.turn-page').length).toBe(0);
expect($book.children('.page').length).toBe(4);
expect($book.children().map((_, el) => $(el).text()).get()).toEqual([
'Page 1',
'Page 2',
'Page 3',
'Page 4'
]);
expect($book.attr('style')).toBeUndefined();
});
it('can initialize again after destroy', () => {
const { $ } = fixture;
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
$book.turn('destroy');
$book.turn({
width: 320,
height: 240,
display: 'single',
gradients: false,
acceleration: false
});
expect($book.turn('size')).toEqual({ width: 320, height: 240 });
expect($book.turn('display')).toBe('single');
expect($book.turn('pages')).toBe(4);
expect($book.find('.turn-page-wrapper').length).toBeGreaterThan(0);
});
it('uses requestAnimationFrame for animatef animations', () => {
fixture.window.close();
const frameCallbacks = [];
let frameHandle = 0;
const requestAnimationFrame = vi.fn(callback => {
frameCallbacks.push(callback);
frameHandle += 1;
return frameHandle;
});
const cancelAnimationFrame = vi.fn();
fixture = createFixture(4, {
performance: { now: vi.fn(() => 0) },
requestAnimationFrame,
cancelAnimationFrame
});
const { $ } = fixture;
const frames = [];
const complete = vi.fn();
const $element = $('<div></div>');
$element.animatef({
from: 0,
to: 10,
duration: 32,
easing: (x, t, b, c, d) => b + c * (t / d),
frame: value => frames.push(value),
complete
});
expect(frames).toEqual([0]);
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
frameCallbacks.shift()(16);
expect(frames[1]).toBe(5);
expect(requestAnimationFrame).toHaveBeenCalledTimes(2);
$element.animatef(false);
expect(cancelAnimationFrame).toHaveBeenCalledWith(2);
frameCallbacks.shift()(32);
expect(frames).toEqual([0, 5]);
expect(complete).not.toHaveBeenCalled();
});
it('writes modern CSS transform properties', () => {
const { $ } = fixture;
const element = $('<div></div>')[0];
$(element).transform('translate(12px, 24px)', '10% 20%');
expect(element.style.transform).toBe('translate(12px, 24px)');
expect(element.style.transformOrigin).toBe('10% 20%');
});
it('registers internal handlers with event namespaces', () => {
fixture.window.close();
fixture = createFixture();
const { $ } = fixture;
const onSpy = vi.spyOn($.fn, 'on');
$('#book').turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false,
when: {
turned: function() {}
}
});
const eventNames = onSpy.mock.calls
.map(call => call[0])
.filter(name => typeof name === 'string');
expect(eventNames).toContain('turned.turn');
expect(eventNames).toContain('pointerdown.turn');
expect(eventNames).toContain('pointermove.turn');
expect(eventNames).toContain('pointerup.turn pointercancel.turn');
expect(eventNames).toContain('pressed.turnFlip');
expect(eventNames).toContain('released.turnFlip');
expect(eventNames).toContain('start.turnFlip');
expect(eventNames).toContain('end.turnFlip');
expect(eventNames).toContain('flip.turnFlip');
});
it('falls back to touch events when Pointer Events are unavailable', () => {
fixture.window.close();
fixture = createFixture(4, {
PointerEvent: undefined
});
const { $ } = fixture;
const onSpy = vi.spyOn($.fn, 'on');
$('#book').turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
const eventNames = onSpy.mock.calls
.map(call => call[0])
.filter(name => typeof name === 'string');
expect(eventNames).toContain('touchstart.turn');
expect(eventNames).toContain('touchmove.turn');
expect(eventNames).toContain('touchend.turn');
});
it('removes pointer event handlers on destroy', () => {
fixture.window.close();
fixture = createFixture();
const { $ } = fixture;
const offSpy = vi.spyOn($.fn, 'off');
const $book = $('#book');
$book.turn({
width: 600,
height: 400,
display: 'double',
gradients: false,
acceleration: false
});
$book.turn('destroy');
const eventNames = offSpy.mock.calls
.map(call => call[0])
.filter(name => typeof name === 'string');
expect(eventNames).toContain('pointerdown.turn');
expect(eventNames).toContain('pointermove.turn');
expect(eventNames).toContain('pointerup.turn pointercancel.turn');
});
});