mediawiki-extensions-Popups/tests/node-qunit/reducers/eventLogging.test.js
Thiemo Kreuz 51dd4b2807 Make nextState support non-flat updates
The nextState() function was not able to understand updates that
are deeper than a single level. Example:

nextState( state, { pagePreviews: { enabled: true } } )

Before, this would replace whatever was in the "pagePreviews"
property with { enabled: true }, but not merge it. In some places
a single level of recursion was done manually because of this.
This can be removed now.

This is done in preparation for splitting the "enabled" flag into
separate ones for each popup type.

Bug: T277639
Change-Id: I35911c18018ba7cd1633a4c882b978656c3fee36
2021-04-12 13:46:18 +02:00

817 lines
16 KiB
JavaScript

import * as counts from '../../../src/counts';
import { createModel } from '../../../src/preview/model';
import eventLogging from '../../../src/reducers/eventLogging';
import actionTypes from '../../../src/actionTypes';
QUnit.module( 'ext.popups/reducers#eventLogging', {
beforeEach() {
this.initialState = eventLogging( undefined, {
type: '@@INIT'
} );
}
} );
QUnit.test( '@@INIT', function ( assert ) {
assert.deepEqual(
this.initialState,
{
previewCount: undefined,
baseData: {},
event: undefined,
interaction: undefined
},
'The initial state is correct.'
);
} );
QUnit.test( 'BOOT', function ( assert ) {
const action = {
type: actionTypes.BOOT,
initiallyEnabled: true,
isNavPopupsEnabled: false,
sessionToken: '0123456789',
pageToken: '9876543210',
page: {
title: 'Foo',
namespaceId: 1,
id: 2
},
user: {
isAnon: false,
editCount: 11,
previewCount: 22
}
};
const expectedEditCountBucket =
counts.getEditCountBucket( action.user.editCount );
const expectedPreviewCountBucket =
counts.getPreviewCountBucket( action.user.previewCount );
let state = eventLogging( this.initialState, action );
assert.deepEqual(
state,
{
previewCount: action.user.previewCount,
baseData: {
pageTitleSource: action.page.title,
namespaceIdSource: action.page.namespaceId,
pageIdSource: action.page.id,
isAnon: action.user.isAnon,
popupEnabled: action.initiallyEnabled,
pageToken: action.pageToken,
sessionToken: action.sessionToken,
editCountBucket: expectedEditCountBucket,
previewCountBucket: expectedPreviewCountBucket,
hovercardsSuppressedByGadget: action.isNavPopupsEnabled
},
event: {
action: 'pageLoaded'
},
interaction: undefined
},
'The boot state is correct.'
);
// ---
// And when the user is logged out...
action.user.isAnon = true;
state = eventLogging( this.initialState, action );
assert.strictEqual(
state.baseData.isAnon,
true,
'The user is anonymous and not logged in.'
);
assert.strictEqual(
state.baseData.editCountBucket,
undefined,
'It shouldn\'t add the editCountBucket property when the user is logged out.'
);
} );
QUnit.test( 'EVENT_LOGGED', ( assert ) => {
let state = {
event: {}
};
let action = {
type: actionTypes.EVENT_LOGGED,
event: {}
};
assert.deepEqual(
eventLogging( state, action ),
{
event: undefined
},
'It dequeues any event queued for logging.'
);
// ---
state = {
interaction: { token: 'asdf' },
event: { linkInteractionToken: 'asdf' }
};
action = {
type: actionTypes.EVENT_LOGGED,
event: state.event
};
assert.deepEqual(
eventLogging( state, action ),
{
event: undefined,
interaction: undefined
},
'It destroys current interaction if an event for it was logged.'
);
} );
QUnit.test( 'PREVIEW_SHOW', ( assert ) => {
const count = 22,
expectedCount = count + 1,
token = '1234567890';
let state = {
previewCount: count,
baseData: {
previewCountBucket: counts.getPreviewCountBucket( count )
},
event: undefined,
// state.interaction.started is used in this part of the reducer.
interaction: {
token
}
};
state = eventLogging( state, {
type: actionTypes.PREVIEW_SHOW,
token
} );
assert.strictEqual(
state.previewCount,
expectedCount,
'It updates the user\'s preview count.'
);
assert.deepEqual(
state.baseData,
{
previewCountBucket: counts.getPreviewCountBucket( expectedCount )
},
'It re-buckets the user\'s preview count.'
);
} );
QUnit.module( 'ext.popups/reducers#eventLogging @integration', {
beforeEach() {
this.link = $( '<a>' ).get( 0 );
}
} );
QUnit.test( 'LINK_DWELL starts an interaction', function ( assert ) {
const state = {
interaction: undefined
};
const action = {
type: actionTypes.LINK_DWELL,
el: this.link,
title: 'Foo',
namespaceId: 1,
token: '0987654321',
timestamp: Date.now()
};
assert.deepEqual(
eventLogging( state, action ),
{
interaction: {
link: action.el,
title: 'Foo',
namespaceId: 1,
token: action.token,
started: action.timestamp,
isUserDwelling: true
},
event: undefined
},
'The link dwell state is correct.'
);
} );
QUnit.test( 'LINK_DWELL doesn\'t start a new interaction under certain conditions', function ( assert ) {
const now = Date.now();
let state = {
interaction: undefined
};
const action = {
type: actionTypes.LINK_DWELL,
el: this.link,
title: 'Foo',
namespaceId: 1,
token: '0987654321',
timestamp: now
};
state = eventLogging( state, action );
action.token = '1234567890';
action.timestamp = now + 200;
state = eventLogging( state, action );
assert.deepEqual(
state.interaction,
{
link: action.el,
title: 'Foo',
namespaceId: 1,
token: '0987654321',
started: now,
isUserDwelling: true
},
'The link dwell state is correct.'
);
} );
QUnit.test( 'LINK_DWELL should enqueue a "dismissed" or "dwelledButAbandoned" event under certain conditions', function ( assert ) {
const token = '0987654321',
now = Date.now();
// Read: The user dwells on link A, abandons it, and dwells on link B fewer
// than 300 ms after (before the ABANDON_END action is reduced).
let state = eventLogging( undefined, {
type: actionTypes.LINK_DWELL,
el: this.link,
title: 'Foo',
namespaceId: 1,
token,
timestamp: now
} );
state = eventLogging( state, {
type: actionTypes.ABANDON_START,
timestamp: now + 250
} );
state = eventLogging( state, {
type: actionTypes.LINK_DWELL,
el: $( '<a>' ),
title: 'Bar',
namespaceId: 1,
token: '1234567890',
timestamp: now + 500
} );
assert.deepEqual(
state.event,
{
pageTitleHover: 'Foo',
namespaceIdHover: 1,
linkInteractionToken: '0987654321',
totalInteractionTime: 250, // 250 - 0
action: 'dwelledButAbandoned'
},
'The link dwell state is correct.'
);
// ---
state = eventLogging( undefined, {
type: actionTypes.LINK_DWELL,
el: this.link,
title: 'Foo',
namespaceId: 1,
token,
timestamp: now
} );
state = eventLogging( state, {
type: actionTypes.LINK_CLICK,
el: this.link
} );
state = eventLogging( state, {
type: actionTypes.LINK_DWELL,
el: $( '<a>' ),
title: 'Bar',
namespaceId: 1,
token: 'banana',
timestamp: now + 500
} );
assert.strictEqual(
state.event,
undefined,
'It shouldn\'t enqueue either event if the interaction is finalized.'
);
} );
QUnit.test( 'LINK_CLICK should enqueue an "opened" event', function ( assert ) {
const token = '0987654321',
now = Date.now();
let state = {
interaction: undefined
};
const expectedState = state = eventLogging( state, {
type: actionTypes.LINK_DWELL,
el: this.link,
title: 'Foo',
namespaceId: 1,
token,
timestamp: now
} );
state = eventLogging( state, {
type: actionTypes.LINK_CLICK,
el: this.link,
timestamp: now + 250
} );
assert.deepEqual(
state.event,
{
action: 'opened',
pageTitleHover: 'Foo',
namespaceIdHover: 1,
linkInteractionToken: token,
totalInteractionTime: 250
},
'The event is enqueued and the totalInteractionTime property is an integer.'
);
expectedState.interaction.finalized = true;
assert.deepEqual(
state.interaction,
expectedState.interaction,
'It should finalize the interaction.'
);
} );
QUnit.test( 'PREVIEW_SHOW should update the perceived wait time of the interaction', function ( assert ) {
const now = Date.now(),
token = '1234567890';
let state = {
interaction: undefined
};
state = eventLogging( state, {
type: actionTypes.LINK_DWELL,
el: this.link,
title: 'Foo',
namespaceId: 1,
token,
timestamp: now
} );
state = eventLogging( state, {
type: actionTypes.PREVIEW_SHOW,
token,
timestamp: now + 500
} );
assert.deepEqual(
state.interaction, {
link: this.link,
title: 'Foo',
namespaceId: 1,
token,
started: now,
isUserDwelling: true,
timeToPreviewShow: 500
},
'The preview show state is correct.'
);
} );
QUnit.test( 'LINK_CLICK should include perceivedWait if the preview has been shown', function ( assert ) {
const token = '0987654321',
now = Date.now();
let state = {
interaction: undefined
};
state = eventLogging( state, {
type: actionTypes.LINK_DWELL,
el: this.link,
title: 'Foo',
namespaceId: 1,
token,
timestamp: now
} );
state = eventLogging( state, {
type: actionTypes.PREVIEW_SHOW,
token,
timestamp: now + 750
} );
state = eventLogging( state, {
type: actionTypes.LINK_CLICK,
el: this.link,
timestamp: now + 1050
} );
assert.deepEqual(
state.event,
{
action: 'opened',
pageTitleHover: 'Foo',
namespaceIdHover: 1,
linkInteractionToken: token,
totalInteractionTime: 1050,
// N.B. that the FETCH_* actions have been skipped.
previewType: undefined,
perceivedWait: 750
},
'The previewType and perceivedWait properties are set if the preview has been shown.'
);
} );
QUnit.test( 'FETCH_COMPLETE', ( assert ) => {
const token = '1234567890',
initialState = {
interaction: {
token
}
},
model = createModel(
'Foo',
'https://en.wikipedia.org/wiki/Foo',
'en',
'ltr',
'',
{}
);
let state = eventLogging( initialState, {
type: actionTypes.FETCH_COMPLETE,
result: model,
token
} );
assert.strictEqual(
state.interaction.previewType,
model.type,
'It mixes in the preview type to the interaction state.'
);
// ---
state = eventLogging( initialState, {
type: actionTypes.FETCH_COMPLETE,
result: model,
token: 'banana'
} );
assert.strictEqual(
initialState,
state,
'It should NOOP if there\'s a new interaction.'
);
// ---
delete initialState.interaction;
state = eventLogging( initialState, {
type: actionTypes.FETCH_COMPLETE,
result: model,
token: '0123456789'
} );
assert.strictEqual(
initialState,
state,
'It should NOOP if the interaction has been finalised.'
);
} );
QUnit.test( 'ABANDON_START', function ( assert ) {
let state = {
interaction: {}
};
state = eventLogging( state, {
type: actionTypes.ABANDON_START,
timestamp: Date.now()
} );
assert.notOk(
state.interaction.isUserDwelling,
'It should mark the link or preview as having been abandoned.'
);
} );
QUnit.test( 'ABANDON_END', function ( assert ) {
let state = {
interaction: {}
};
let action = {
type: actionTypes.LINK_DWELL,
el: this.link,
title: 'Foo',
namespaceId: 1,
token: '1234567890',
timestamp: Date.now()
};
state = eventLogging( state, action );
action = {
type: actionTypes.ABANDON_END,
token: '1234567890'
};
assert.deepEqual(
eventLogging( state, action ),
state,
'ABANDON_END should NOOP if the user is dwelling on the preview or the link.'
);
// ---
action.token = '0987654321';
assert.deepEqual(
eventLogging( state, action ),
state,
'ABANDON_END should NOOP if the current interaction has changed.'
);
} );
QUnit.test( 'PREVIEW_DWELL', ( assert ) => {
let state = {
interaction: {}
};
state = eventLogging( state, {
type: actionTypes.PREVIEW_DWELL
} );
assert.ok(
state.interaction.isUserDwelling,
'It should mark the link or preview as being dwelled on.'
);
} );
QUnit.test( 'SETTINGS_SHOW should enqueue a "tapped settings cog" event', function ( assert ) {
const initialState = {
interaction: { started: 0, finished: 0 }
},
token = '0123456789';
let state = eventLogging( initialState, {
type: actionTypes.SETTINGS_SHOW
} );
// Note well that this is a valid event. The "tapped settings cog" event is
// also logged as a result of clicking the footer link.
assert.deepEqual(
state.event,
{
action: 'tapped settings cog',
linkInteractionToken: undefined,
namespaceIdHover: undefined,
pageTitleHover: undefined
},
'It shouldn\'t fail if there\'s no interaction.'
);
// ---
state = eventLogging( initialState, {
type: actionTypes.LINK_DWELL,
el: this.link,
title: 'Foo',
namespaceId: 1,
token,
timestamp: Date.now()
} );
state = eventLogging( state, {
type: actionTypes.SETTINGS_SHOW
} );
assert.deepEqual(
state.event,
{
action: 'tapped settings cog',
linkInteractionToken: token,
totalInteractionTime: 0,
namespaceIdHover: 1,
pageTitleHover: 'Foo'
},
'It should include the interaction information if there\'s an interaction.'
);
} );
QUnit.test( 'SETTINGS_CHANGE should enqueue disabled event', ( assert ) => {
let state = eventLogging( undefined, {
type: actionTypes.SETTINGS_CHANGE,
oldValue: false,
newValue: false
} );
assert.strictEqual(
state.event,
undefined,
'It shouldn\'t enqueue a "disabled" event when there is no change'
);
state = eventLogging( state, {
type: actionTypes.SETTINGS_CHANGE,
oldValue: true,
newValue: false
} );
assert.deepEqual(
state.event,
{
action: 'disabled',
popupEnabled: false
},
'It should enqueue a "disabled" event when the previews has been disabled'
);
delete state.event;
state = eventLogging( state, {
type: actionTypes.SETTINGS_CHANGE,
oldValue: false,
newValue: true
} );
assert.strictEqual(
state.event,
undefined,
'It shouldn\'t enqueue a "disabled" event when page previews has been enabled'
);
} );
QUnit.test( 'ABANDON_END should enqueue an event', function ( assert ) {
const token = '0987654321',
now = Date.now();
const dwelledState = eventLogging( undefined, {
type: actionTypes.LINK_DWELL,
el: this.link,
title: 'Foo',
namespaceId: 1,
token,
timestamp: now
} );
let state = eventLogging( dwelledState, {
type: actionTypes.ABANDON_START,
token,
timestamp: now + 500
} );
state = eventLogging( state, {
type: actionTypes.ABANDON_END,
token
} );
assert.deepEqual(
state.event,
{
pageTitleHover: 'Foo',
namespaceIdHover: 1,
linkInteractionToken: token,
totalInteractionTime: 500,
action: 'dwelledButAbandoned'
},
'It should enqueue a "dwelledButAbandoned" event when the preview hasn\'t been shown.'
);
assert.strictEqual(
state.interaction,
undefined,
'It should close the interaction.'
);
// ---
state = eventLogging( dwelledState, {
type: actionTypes.PREVIEW_SHOW,
token,
timestamp: now + 700
} );
state = eventLogging( state, {
type: actionTypes.ABANDON_START,
token,
timestamp: now + 850
} );
state = eventLogging( state, {
type: actionTypes.ABANDON_END,
token
} );
assert.deepEqual(
state.event,
{
pageTitleHover: 'Foo',
namespaceIdHover: 1,
linkInteractionToken: token,
totalInteractionTime: 850,
action: 'dismissed',
// N.B. that the FETCH_* actions have been skipped.
previewType: undefined,
perceivedWait: 700
},
'It should enqueue a "dismissed" event when the preview has been shown.'
);
} );
QUnit.test( 'ABANDON_END doesn\'t enqueue an event under certain conditions', function ( assert ) {
const token = '0987654321',
now = Date.now();
const dwelledState = eventLogging( undefined, {
type: actionTypes.LINK_DWELL,
el: this.link,
title: 'Foo',
namespaceId: 1,
token,
timestamp: now
} );
let state = eventLogging( dwelledState, {
type: actionTypes.ABANDON_END,
token: '1234567890'
} );
assert.strictEqual(
state.event,
undefined,
'It shouldn\'t enqueue an event if there\'s a new interaction.'
);
// ---
state = eventLogging( dwelledState, {
type: actionTypes.ABANDON_END,
token
} );
assert.strictEqual(
state.event,
undefined,
'It shouldn\'t enqueue an event if the user is dwelling on the preview or the link.'
);
// ---
state = eventLogging( dwelledState, {
type: actionTypes.LINK_CLICK,
timestamp: now + 500
} );
state = eventLogging( state, {
type: actionTypes.EVENT_LOGGED,
event: {}
} );
state = eventLogging( state, {
type: actionTypes.ABANDON_START,
token,
timestamp: now + 700
} );
state = eventLogging( state, {
type: actionTypes.ABANDON_END,
token,
timestamp: now + 1000 // ABANDON_END_DELAY is 300 ms.
} );
assert.strictEqual(
state.event,
undefined,
'It shouldn\'t enqueue an event if the interaction is finalized.'
);
} );