mediawiki-extensions-Popups/tests/node-qunit/actions.test.js
Jon Robson 2c09fd1d1c Generalize settings code (attempt 2)
This reverts commit a6a65204c6.
to restore custom preview types.

-- Changes since revert
The previous patch accidentally removed the syncUserSettings
changeListener. This has now been restored with several modifications:
* We have a migrate script which rewrites existing localStorage settings
to the new system
* The existing save functions are generalized.

The changes since this patch are captured in
Ia73467799a9b535f7a3cf7268727c9fab7af0d7e

-- More information

A new REGISTER_SETTING action replaces the BOOT action for
registering settings. This allows custom preview types to be
associated with a setting. They do this by adding the enabled
property to the module they provide to mw.popups.register

Every time the new action is called, we refresh the settings
dialog UI with the new settings.

Previously the settings dialog was hardcoded, but now it is generated
from the registered preview types by deriving associated messages
and checking they exist, so by default custom types will not show
up in the settings.

Benefits:
* This change empowers us to add a setting for Math previews to allow
them to be enabled or disabled.
* Allows us to separate references as its own module

Additional notes:
* The syncUserSettings.js changeListener is no longer needed as the logic
for this is handled inside the "userSettings" change listener in response to
the "settings" reducer which is responding to
SETTINGS_CHANGE and REGISTER_SETTING actions.

Upon merging:
* https://www.mediawiki.org/wiki/Extension:Popups#Extensibility will be
updated to detail how a setting can be registered.

Bug: T334261
Bug: T326692

Change-Id: Ie17d622870511ac9730fc9fa525698fc3aa0d5b6
2024-01-09 17:24:09 -08:00

633 lines
14 KiB
JavaScript

import { createStubUser, createStubTitle } from './stubs';
import * as actions from '../../src/actions';
import * as WaitModule from '../../src/wait';
import actionTypes from '../../src/actionTypes';
import { setDwellTime, previewTypes } from '../../src/preview/model';
const REFERRER = 'https://en.wikipedia.org/wiki/Kitten',
TEST_TITLE = createStubTitle( 0, 'Foo' );
function generateToken() {
return 'ABC';
}
QUnit.module( 'ext.popups/actions' );
QUnit.test( '#boot', ( assert ) => {
const config = new Map(),
stubUser = createStubUser( /* isAnon = */ true );
config.set( 'wgTitle', 'Foo' );
config.set( 'wgNamespaceNumber', 0 );
config.set( 'wgArticleId', 2 );
config.set( 'wgUserEditCount', 3 );
config.set( 'wgPopupsConflictsWithNavPopupGadget', true );
const stubUserSettings = {
getPreviewCount() {
return 22;
}
};
const action = actions.boot(
{ page: false },
stubUser,
stubUserSettings,
config,
REFERRER
);
assert.deepEqual(
action,
{
type: actionTypes.BOOT,
initiallyEnabled: { page: false },
isNavPopupsEnabled: true,
sessionToken: '0123456789',
pageToken: '9876543210',
page: {
url: REFERRER,
title: 'Foo',
namespaceId: 0,
id: 2
},
user: {
isAnon: true,
editCount: 3
}
},
'boots with the initial state'
);
} );
QUnit.test( '#registerSetting', ( assert ) => {
const action = actions.registerSetting(
'foo',
false
);
assert.deepEqual(
action,
{
type: actionTypes.REGISTER_SETTING,
name: 'foo',
enabled: false
},
'Setting action'
);
} );
/**
* Stubs `wait.js` and adds the deferred and its promise as properties
* of the module.
*
* @param {Object} module
*/
function setupWait( module ) {
module.waitPromise = $.Deferred().resolve().promise( { abort() {} } );
module.wait = module.sandbox.stub( WaitModule, 'default' ).callsFake(
() => module.waitPromise
);
}
/**
* Sets up a link/mw.Title stub pair that can be passed to the linkDwell action
* creator.
*
* @param {Object} module
*/
function setupEl( module ) {
module.title = TEST_TITLE;
module.el = $( '<a>' ).get( 0 );
}
QUnit.module( 'ext.popups/actions#linkDwell @integration', {
beforeEach() {
this.state = {
preview: {}
};
this.getState = () => this.state;
// The worst-case implementation of mw.now.
mw.now = () => Date.now();
setupEl( this );
}
} );
QUnit.test( '#linkDwell', function ( assert ) {
const measures = {},
dispatch = this.sandbox.spy();
this.sandbox.stub( mw, 'now' ).returns( new Date() );
this.sandbox.stub( actions, 'fetch' );
// Stub the state tree being updated by the LINK_DWELL action.
this.state.preview = {
activeToken: generateToken()
};
const linkDwelled = actions.linkDwell(
this.title, this.el, measures, null, generateToken, previewTypes.TYPE_PAGE
)(
dispatch,
this.getState
);
assert.propEqual(
dispatch.getCall( 0 ).args[ 0 ], {
type: actionTypes.LINK_DWELL,
el: this.el,
previewType: 'page',
measures,
token: 'ABC',
timestamp: mw.now(),
title: 'Foo',
namespaceId: 0,
promise: Promise.resolve()
},
'The dispatcher was called with the correct arguments.'
);
// Stub the state tree being updated.
this.state.preview = {
enabled: { page: true },
activeLink: this.el,
activeToken: generateToken()
};
// ---
return linkDwelled.then( () => {
assert.strictEqual(
dispatch.callCount,
2,
'The fetch action is dispatched after FETCH_COMPLETE milliseconds.'
);
} );
} );
QUnit.test( '#linkDwell doesn\'t continue when previews are disabled', function ( assert ) {
const event = {},
dispatch = this.sandbox.spy();
// Stub the state tree being updated by the LINK_DWELL action.
this.state.preview = {
enabled: { page: false },
activeLink: this.el,
activeToken: generateToken()
};
const linkDwelled = actions.linkDwell(
this.title, this.el, event, /* gateway = */ null, generateToken, previewTypes.TYPE_PAGE
)(
dispatch,
this.getState
);
assert.strictEqual(
dispatch.callCount,
1,
'The dispatcher was called once.'
);
return linkDwelled.then( () => {
assert.strictEqual(
dispatch.callCount,
1,
'The dispatcher was not called again.'
);
} );
} );
QUnit.test( '#linkDwell doesn\'t continue if the token has changed', function ( assert ) {
const event = {},
dispatch = this.sandbox.spy();
// Stub the state tree being updated by a LINK_DWELL action.
this.state.preview = {
enabled: { page: true },
activeLink: this.el,
activeToken: generateToken()
};
const linkDwelled = actions.linkDwell(
this.title, this.el, event, /* gateway = */ null, generateToken, previewTypes.TYPE_PAGE
)(
dispatch,
this.getState
);
// Stub the state tree being updated by another LINK_DWELL action.
this.state.preview = {
enabled: { page: true },
// Consider the user tabbing back and forth between two links in the time
// it takes to start fetching data via the gateway: the active link hasn't
// changed, but the active token has.
activeLink: this.el,
activeToken: 'banana'
};
return linkDwelled.then( () => {
assert.strictEqual(
dispatch.callCount,
1,
'The dispatcher was called once.'
);
} );
} );
QUnit.test( '#linkDwell dispatches the fetch action', function ( assert ) {
const event = {},
dispatch = this.sandbox.spy();
this.state.preview = {
enabled: { page: true },
activeToken: generateToken()
};
return actions.linkDwell(
this.title, this.el, event, /* gateway = */ null, generateToken, previewTypes.TYPE_PAGE
)(
dispatch,
this.getState
).then( () => {
assert.strictEqual(
dispatch.callCount,
2,
'The dispatcher was called twice.'
);
} );
} );
QUnit.module( 'ext.popups/actions#fetch', {
beforeEach() {
this.now = 0;
this.sandbox.stub( mw, 'now' ).callsFake( () => this.now );
setupWait( this );
setupEl( this );
this.gatewayDeferred = $.Deferred();
this.gateway = {
fetchPreviewForTitle: this.sandbox.stub().returns(
this.gatewayDeferred.promise( { abort() {} } )
)
};
this.dispatch = this.sandbox.spy();
this.token = '1234567890';
// Sugar.
setDwellTime( previewTypes.TYPE_PAGE, 350 );
this.fetch = () => {
return actions.fetch(
this.gateway, this.title, this.el, this.token, previewTypes.TYPE_PAGE
)( this.dispatch );
};
}
} );
QUnit.test( 'it should fetch data from the gateway immediately', function ( assert ) {
this.fetch();
assert.true(
this.gateway.fetchPreviewForTitle.calledWith( TEST_TITLE ),
'The gateway was called with the correct arguments.'
);
assert.strictEqual( this.dispatch.callCount, 1 );
assert.propEqual(
this.dispatch.getCall( 0 ).args[ 0 ],
{
type: actionTypes.FETCH_START,
el: this.el,
title: 'Foo',
namespaceId: 0,
timestamp: this.now,
promise: $.Deferred().promise( { abort() {} } )
},
'It dispatches the FETCH_START action immediately.'
);
} );
QUnit.test( 'it should dispatch the FETCH_END action when the API request ends', function ( assert ) {
const fetched = this.fetch();
this.now += 115;
this.gatewayDeferred.resolve( {} );
return fetched.then( () => {
assert.deepEqual(
this.dispatch.getCall( 1 ).args[ 0 ],
{
type: actionTypes.FETCH_END,
el: this.el,
timestamp: 115
},
'The dispatcher was called with the correct arguments.'
);
} );
} );
QUnit.test( 'it should delay dispatching the FETCH_COMPLETE action', function ( assert ) {
const result = {},
fetched = this.fetch();
assert.strictEqual(
this.wait.getCall( 0 ).args[ 0 ],
350,
'It waits for FETCH_COMPLETE_TARGET_DELAY - FETCH_START_DELAY milliseconds.'
);
this.gatewayDeferred.resolve( result );
return fetched.then( () => {
assert.deepEqual(
this.dispatch.getCall( 2 ).args[ 0 ],
{
type: actionTypes.FETCH_COMPLETE,
el: this.el,
result,
token: this.token
},
'The dispatcher was called with the correct arguments.'
);
} );
} );
QUnit.test( 'it should dispatch the FETCH_FAILED action when the request fails', function ( assert ) {
const fetched = this.fetch();
this.gatewayDeferred.reject( new Error( 'API req failed' ) );
this.now += 115;
return fetched.then( () => {
assert.strictEqual(
this.dispatch.callCount, 3,
'dispatch called thrice, START, FAILED, and COMPLETE'
);
assert.deepEqual(
this.dispatch.getCall( 1 ).args[ 0 ],
{
type: actionTypes.FETCH_FAILED,
el: this.el,
token: this.token
},
'The dispatcher was called with the correct arguments.'
);
} );
} );
QUnit.test( 'it should dispatch the FETCH_FAILED action when the request fails even after the wait timeout', function ( assert ) {
// After the wait interval happens, resolve the gateway request
return this.waitPromise.then( () => {
this.gatewayDeferred.reject( new Error( 'API req failed' ) );
return this.fetch();
} ).then( () => {
assert.strictEqual(
this.dispatch.callCount, 3,
'dispatch called thrice, START, FAILED, and COMPLETE'
);
assert.deepEqual(
this.dispatch.getCall( 1 ).args[ 0 ],
{
type: actionTypes.FETCH_FAILED,
el: this.el,
token: this.token
},
'The dispatcher was called with the correct arguments.'
);
} );
} );
QUnit.test( 'it should dispatch the FETCH_ABORTED action when the request is aborted', function ( assert ) {
const fetched = this.fetch();
this.now += 115;
this.gatewayDeferred.reject( 'http', {
textStatus: 'abort',
exception: 'abort',
xhr: {
readyState: 0
}
} );
return fetched.then( () => {
assert.strictEqual(
this.dispatch.callCount, 2,
'dispatch called twice with START and ABORT'
);
assert.deepEqual(
this.dispatch.getCall( 1 ).args[ 0 ],
{
type: actionTypes.FETCH_ABORTED,
el: this.el,
token: this.token
},
'The dispatcher was called with the correct arguments.'
);
} );
} );
QUnit.module( 'ext.popups/actions#abandon', {
beforeEach() {
setupWait( this );
setupEl( this );
}
} );
QUnit.test( 'it should dispatch start and end actions', function ( assert ) {
const dispatch = this.sandbox.spy(),
token = '0123456789',
getState = () =>
( {
preview: {
activeToken: token,
promise: $.Deferred().promise( { abort() {} } )
}
} );
this.sandbox.stub( mw, 'now' ).returns( new Date() );
const abandoned = actions.abandon()( dispatch, getState );
assert.true(
dispatch.calledWith( {
type: actionTypes.ABANDON_START,
timestamp: mw.now(),
token
} ),
'The dispatcher was called with the correct arguments.'
);
// ---
assert.true(
this.wait.calledWith( 300 ),
'Have you spoken with #Design about changing this value?'
);
return abandoned.then( () => {
assert.true(
dispatch.calledWith( {
type: actionTypes.ABANDON_END,
token
} ),
'ABANDON_* share the same token.'
);
} );
} );
QUnit.test( 'it shouldn\'t dispatch under certain conditions', function ( assert ) {
const dispatch = this.sandbox.spy(),
getState = () =>
( {
preview: {
activeToken: undefined
}
} );
return actions.abandon()( dispatch, getState )
.then( () => {
assert.strictEqual(
dispatch.callCount,
0,
'The dispatcher was not called.'
);
} );
} );
QUnit.module( 'ext.popups/actions#saveSettings' );
QUnit.test( 'it should dispatch an action with previous and current enabled state', function ( assert ) {
const dispatch = this.sandbox.spy(),
getState = this.sandbox.stub().returns( {
preview: {
enabled: { page: false }
}
} );
actions.saveSettings( { page: true } )( dispatch, getState );
assert.true(
getState.calledOnce,
'it should query the global state for the current state'
);
assert.true(
dispatch.calledWith( {
type: actionTypes.SETTINGS_CHANGE,
oldValue: { page: false },
newValue: { page: true }
} ),
'it should dispatch the action with the previous and next enabled state'
);
} );
QUnit.module( 'ext.popups/actions#previewShow', {
beforeEach() {
setupWait( this );
}
} );
QUnit.test( 'it should dispatch the PREVIEW_SHOW action and log a pageview', function ( assert ) {
const token = '1234567890',
dispatch = this.sandbox.spy(),
getState = this.sandbox.stub().returns( {
preview: {
activeToken: token,
fetchResponse: {
title: 'A',
pageId: 42,
type: 'page'
}
}
} );
this.sandbox.stub( mw, 'now' ).returns( new Date() );
const previewShow = actions
.previewShow( token )( dispatch, getState );
assert.true(
dispatch.calledWith( {
type: actionTypes.PREVIEW_SHOW,
token,
timestamp: mw.now()
} ),
'dispatches the preview show event'
);
assert.strictEqual(
this.wait.getCall( 0 ).args[ 0 ],
1000,
'It waits for PAGEVIEW_VISIBILITY_DURATION milliseconds before trigging a pageview.'
);
return previewShow.then( () => {
assert.true(
dispatch.calledTwice,
'Dispatch was called twice - once for PREVIEW_SHOW then for PREVIEW_SEEN'
);
assert.true(
dispatch.calledWith( {
type: actionTypes.PREVIEW_SEEN,
namespace: 0,
pageId: 42,
title: 'A'
} ),
'Dispatches virtual pageview'
);
} );
} );
QUnit.test( 'PREVIEW_SEEN action not called if activeToken changes', function ( assert ) {
const token = '1234567890',
dispatch = this.sandbox.spy(),
getState = this.sandbox.stub().returns( {
preview: {
activeToken: '911',
fetchResponse: {
title: 'A',
type: 'page'
}
}
} );
// dispatch event
const previewShow = actions
.previewShow( token )( dispatch, getState );
return previewShow.then( () => {
assert.true(
dispatch.calledOnce,
'Dispatch was only called for PREVIEW_SHOW'
);
} );
} );
QUnit.test( 'PREVIEW_SEEN action not called if preview type not page', function ( assert ) {
const token = '1234567890',
dispatch = this.sandbox.spy(),
getState = this.sandbox.stub().returns( {
preview: {
activeToken: token,
fetchResponse: {
title: 'A',
type: 'empty'
}
}
} );
// dispatch event
const previewShow = actions
.previewShow( token )( dispatch, getState );
return previewShow.then( () => {
assert.true(
dispatch.calledOnce,
'Dispatch was only called for PREVIEW_SHOW'
);
} );
} );