mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-19 16:32:00 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,88 @@
|
|||
odoo.define('web.test_env', async function (require) {
|
||||
"use strict";
|
||||
|
||||
const Bus = require('web.Bus');
|
||||
const session = require('web.session');
|
||||
const { makeTestEnvServices } = require('@web/../tests/legacy/helpers/test_services');
|
||||
const { templates, setLoadXmlDefaultApp } = require("@web/core/assets");
|
||||
const { renderToString } = require('@web/core/utils/render');
|
||||
const { App, Component } = owl;
|
||||
|
||||
let app;
|
||||
|
||||
/**
|
||||
* Creates a test environment with the given environment object.
|
||||
* Any access to a key that has not been explicitly defined in the given environment object
|
||||
* will result in an error.
|
||||
*
|
||||
* @param {Object} [env={}]
|
||||
* @param {Function} [providedRPC=null]
|
||||
* @returns {Proxy}
|
||||
*/
|
||||
function makeTestEnvironment(env = {}, providedRPC = null) {
|
||||
if (!app) {
|
||||
app = new App(null, { templates, test: true });
|
||||
renderToString.app = app;
|
||||
setLoadXmlDefaultApp(app);
|
||||
}
|
||||
|
||||
const defaultTranslationParamters = {
|
||||
code: "en_US",
|
||||
date_format: '%m/%d/%Y',
|
||||
decimal_point: ".",
|
||||
direction: 'ltr',
|
||||
grouping: [],
|
||||
thousands_sep: ",",
|
||||
time_format: '%H:%M:%S',
|
||||
};
|
||||
|
||||
let _t;
|
||||
if ('_t' in env) {
|
||||
_t = Object.assign(env._t, {database: env._t.database || {}})
|
||||
} else {
|
||||
_t = Object.assign(((s) => s), { database: {} });
|
||||
}
|
||||
|
||||
_t.database.parameters = Object.assign(defaultTranslationParamters, _t.database.parameters);
|
||||
|
||||
const defaultEnv = {
|
||||
_t,
|
||||
browser: Object.assign({
|
||||
setTimeout: window.setTimeout.bind(window),
|
||||
clearTimeout: window.clearTimeout.bind(window),
|
||||
setInterval: window.setInterval.bind(window),
|
||||
clearInterval: window.clearInterval.bind(window),
|
||||
requestAnimationFrame: window.requestAnimationFrame.bind(window),
|
||||
Date: window.Date,
|
||||
fetch: (window.fetch || (() => { })).bind(window),
|
||||
}, env.browser),
|
||||
bus: env.bus || new Bus(),
|
||||
device: Object.assign({
|
||||
isMobile: false,
|
||||
SIZES: { XS: 0, VSM: 1, SM: 2, MD: 3, LG: 4, XL: 5, XXL: 6 },
|
||||
}, env.device),
|
||||
isDebug: env.isDebug || (() => false),
|
||||
services: makeTestEnvServices(env),
|
||||
session: Object.assign({
|
||||
rpc(route, params, options) {
|
||||
if (providedRPC) {
|
||||
return providedRPC(route, params, options);
|
||||
}
|
||||
throw new Error(`No method to perform RPC`);
|
||||
},
|
||||
url: session.url,
|
||||
getTZOffset: (() => 0),
|
||||
}, env.session),
|
||||
};
|
||||
return Object.assign(env, defaultEnv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Before each test, we want Component.env to be a fresh test environment.
|
||||
*/
|
||||
QUnit.on('OdooBeforeTestHook', function () {
|
||||
Component.env = makeTestEnvironment();
|
||||
});
|
||||
|
||||
return makeTestEnvironment;
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { buildQuery } from 'web.rpc';
|
||||
|
||||
const testEnvServices = {
|
||||
getCookie() {},
|
||||
httpRequest(/* route, params = {}, readMethod = 'json' */) {
|
||||
return Promise.resolve('');
|
||||
},
|
||||
hotkey: { add: () => () => {} }, // fake service
|
||||
notification: { notify() {} },
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates services for the test environment. object
|
||||
*
|
||||
* @param {Object} [env]
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeTestEnvServices(env) {
|
||||
return Object.assign({}, testEnvServices, {
|
||||
ajax: {
|
||||
rpc() {
|
||||
return env.session.rpc(...arguments); // Compatibility Legacy Widgets
|
||||
},
|
||||
},
|
||||
rpc(params, options) {
|
||||
const query = buildQuery(params);
|
||||
return env.session.rpc(query.route, query.params, options);
|
||||
},
|
||||
ui: { activeElement: document }, // fake service
|
||||
}, env.services);
|
||||
}
|
||||
|
||||
export {
|
||||
makeTestEnvServices,
|
||||
testEnvServices,
|
||||
};
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
odoo.define('web.test_utils', async function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Test Utils
|
||||
*
|
||||
* In this module, we define various utility functions to help simulate a mock
|
||||
* environment as close as possible as a real environment. The main function is
|
||||
* certainly createView, which takes a bunch of parameters and give you back an
|
||||
* instance of a view, appended in the dom, ready to be tested.
|
||||
*/
|
||||
|
||||
const relationalFields = require('web.relational_fields');
|
||||
const session = require('web.session');
|
||||
const testUtilsCreate = require('web.test_utils_create');
|
||||
const testUtilsControlPanel = require('web.test_utils_control_panel');
|
||||
const testUtilsDom = require('web.test_utils_dom');
|
||||
const testUtilsFields = require('web.test_utils_fields');
|
||||
const testUtilsFile = require('web.test_utils_file');
|
||||
const testUtilsForm = require('web.test_utils_form');
|
||||
const testUtilsGraph = require('web.test_utils_graph');
|
||||
const testUtilsKanban = require('web.test_utils_kanban');
|
||||
const testUtilsMock = require('web.test_utils_mock');
|
||||
const testUtilsModal = require('web.test_utils_modal');
|
||||
const testUtilsPivot = require('web.test_utils_pivot');
|
||||
const tools = require('web.tools');
|
||||
|
||||
|
||||
function deprecated(fn, type) {
|
||||
const msg = `Helper 'testUtils.${fn.name}' is deprecated. ` +
|
||||
`Please use 'testUtils.${type}.${fn.name}' instead.`;
|
||||
return tools.deprecated(fn, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function, make a promise with a public resolve function. Note that
|
||||
* this is not standard and should not be used outside of tests...
|
||||
*
|
||||
* @returns {Promise + resolve and reject function}
|
||||
*/
|
||||
function makeTestPromise() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise(function (_resolve, _reject) {
|
||||
resolve = _resolve;
|
||||
reject = _reject;
|
||||
});
|
||||
promise.resolve = function () {
|
||||
resolve.apply(null, arguments);
|
||||
return promise;
|
||||
};
|
||||
promise.reject = function () {
|
||||
reject.apply(null, arguments);
|
||||
return promise;
|
||||
};
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a promise with public resolve and reject functions (see
|
||||
* @makeTestPromise). Perform an assert.step when the promise is
|
||||
* resolved/rejected.
|
||||
*
|
||||
* @param {Object} assert instance object with the assertion methods
|
||||
* @param {function} assert.step
|
||||
* @param {string} str message to pass to assert.step
|
||||
* @returns {Promise + resolve and reject function}
|
||||
*/
|
||||
function makeTestPromiseWithAssert(assert, str) {
|
||||
const prom = makeTestPromise();
|
||||
prom.then(() => assert.step('ok ' + str)).catch(function () { });
|
||||
prom.catch(() => assert.step('ko ' + str));
|
||||
return prom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new promise that can be waited by the caller in order to execute
|
||||
* code after the next microtask tick and before the next jobqueue tick.
|
||||
*
|
||||
* @return {Promise} an already fulfilled promise
|
||||
*/
|
||||
async function nextMicrotaskTick() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that will be resolved after the tick after the
|
||||
* nextAnimationFrame
|
||||
*
|
||||
* This is usefull to guarantee that OWL has had the time to render
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function nextTick() {
|
||||
return testUtilsDom.returnAfterNextAnimationFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaits for an additionnal rendering frame initiated by the Owl
|
||||
* compatibility layer processing.
|
||||
*
|
||||
* By default a simple "nextTick" will handle the rendering of any widget/
|
||||
* component stuctures having at most 1 switch between the type of
|
||||
* entities (Component > Widget or Widget > Component). However more time
|
||||
* must be spent rendering in case we have additionnal switches. In such
|
||||
* cases this function must be used (1 call for each additionnal switch)
|
||||
* since it will be removed along with the compatiblity layer once the
|
||||
* framework has been entirely converted, and using this helper will make
|
||||
* it easier to wipe it from the code base.
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function owlCompatibilityExtraNextTick() {
|
||||
return testUtilsDom.returnAfterNextAnimationFrame();
|
||||
}
|
||||
|
||||
// Loading static files cannot be properly simulated when their real content is
|
||||
// really needed. This is the case for static XML files so we load them here,
|
||||
// before starting the qunit test suite.
|
||||
// (session.js is in charge of loading the static xml bundle and we also have
|
||||
// to load xml files that are normally lazy loaded by specific widgets).
|
||||
// Assets can also contain static xml files. They are loaded when the session
|
||||
// is launched.
|
||||
await session.is_bound;
|
||||
setTimeout(function () {
|
||||
// jquery autocomplete refines the search in a setTimeout() parameterized
|
||||
// with a delay, so we force this delay to 0 s.t. the dropdown is filtered
|
||||
// directly on the next tick
|
||||
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
|
||||
}, 0);
|
||||
return {
|
||||
mock: {
|
||||
addMockEnvironment: testUtilsMock.addMockEnvironment,
|
||||
addMockEnvironmentOwl: testUtilsMock.addMockEnvironmentOwl,
|
||||
intercept: testUtilsMock.intercept,
|
||||
patch: testUtilsMock.patch,
|
||||
patchDate: testUtilsMock.patchDate,
|
||||
unpatch: testUtilsMock.unpatch,
|
||||
getView: testUtilsMock.getView,
|
||||
patchSetTimeout: testUtilsMock.patchSetTimeout,
|
||||
},
|
||||
controlPanel: {
|
||||
// Generic interactions
|
||||
toggleMenu: testUtilsControlPanel.toggleMenu,
|
||||
toggleMenuItem: testUtilsControlPanel.toggleMenuItem,
|
||||
toggleMenuItemOption: testUtilsControlPanel.toggleMenuItemOption,
|
||||
isItemSelected: testUtilsControlPanel.isItemSelected,
|
||||
isOptionSelected: testUtilsControlPanel.isOptionSelected,
|
||||
getMenuItemTexts: testUtilsControlPanel.getMenuItemTexts,
|
||||
// Button interactions
|
||||
getButtons: testUtilsControlPanel.getButtons,
|
||||
// FilterMenu interactions
|
||||
toggleFilterMenu: testUtilsControlPanel.toggleFilterMenu,
|
||||
toggleAddCustomFilter: testUtilsControlPanel.toggleAddCustomFilter,
|
||||
applyFilter: testUtilsControlPanel.applyFilter,
|
||||
addCondition: testUtilsControlPanel.addCondition,
|
||||
// GroupByMenu interactions
|
||||
toggleGroupByMenu: testUtilsControlPanel.toggleGroupByMenu,
|
||||
toggleAddCustomGroup: testUtilsControlPanel.toggleAddCustomGroup,
|
||||
selectGroup: testUtilsControlPanel.selectGroup,
|
||||
applyGroup: testUtilsControlPanel.applyGroup,
|
||||
// FavoriteMenu interactions
|
||||
toggleFavoriteMenu: testUtilsControlPanel.toggleFavoriteMenu,
|
||||
toggleSaveFavorite: testUtilsControlPanel.toggleSaveFavorite,
|
||||
editFavoriteName: testUtilsControlPanel.editFavoriteName,
|
||||
saveFavorite: testUtilsControlPanel.saveFavorite,
|
||||
deleteFavorite: testUtilsControlPanel.deleteFavorite,
|
||||
// ComparisonMenu interactions
|
||||
toggleComparisonMenu: testUtilsControlPanel.toggleComparisonMenu,
|
||||
// SearchBar interactions
|
||||
getFacetTexts: testUtilsControlPanel.getFacetTexts,
|
||||
removeFacet: testUtilsControlPanel.removeFacet,
|
||||
editSearch: testUtilsControlPanel.editSearch,
|
||||
validateSearch: testUtilsControlPanel.validateSearch,
|
||||
// Action menus interactions
|
||||
toggleActionMenu: testUtilsControlPanel.toggleActionMenu,
|
||||
// Pager interactions
|
||||
pagerPrevious: testUtilsControlPanel.pagerPrevious,
|
||||
pagerNext: testUtilsControlPanel.pagerNext,
|
||||
getPagerValue: testUtilsControlPanel.getPagerValue,
|
||||
getPagerSize: testUtilsControlPanel.getPagerSize,
|
||||
setPagerValue: testUtilsControlPanel.setPagerValue,
|
||||
// View switcher
|
||||
switchView: testUtilsControlPanel.switchView,
|
||||
},
|
||||
dom: {
|
||||
triggerKeypressEvent: testUtilsDom.triggerKeypressEvent,
|
||||
triggerMouseEvent: testUtilsDom.triggerMouseEvent,
|
||||
triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent,
|
||||
triggerPositionalTapEvents: testUtilsDom.triggerPositionalTapEvents,
|
||||
dragAndDrop: testUtilsDom.dragAndDrop,
|
||||
find: testUtilsDom.findItem,
|
||||
getNode: testUtilsDom.getNode,
|
||||
openDatepicker: testUtilsDom.openDatepicker,
|
||||
click: testUtilsDom.click,
|
||||
clickFirst: testUtilsDom.clickFirst,
|
||||
clickLast: testUtilsDom.clickLast,
|
||||
triggerEvents: testUtilsDom.triggerEvents,
|
||||
triggerEvent: testUtilsDom.triggerEvent,
|
||||
},
|
||||
form: {
|
||||
clickEdit: testUtilsForm.clickEdit,
|
||||
clickSave: testUtilsForm.clickSave,
|
||||
clickCreate: testUtilsForm.clickCreate,
|
||||
clickDiscard: testUtilsForm.clickDiscard,
|
||||
reload: testUtilsForm.reload,
|
||||
},
|
||||
graph: {
|
||||
reload: testUtilsGraph.reload,
|
||||
},
|
||||
kanban: {
|
||||
reload: testUtilsKanban.reload,
|
||||
clickCreate: testUtilsKanban.clickCreate,
|
||||
quickCreate: testUtilsKanban.quickCreate,
|
||||
toggleGroupSettings: testUtilsKanban.toggleGroupSettings,
|
||||
toggleRecordDropdown: testUtilsKanban.toggleRecordDropdown,
|
||||
},
|
||||
modal: {
|
||||
clickButton: testUtilsModal.clickButton,
|
||||
},
|
||||
pivot: {
|
||||
clickMeasure: testUtilsPivot.clickMeasure,
|
||||
toggleMeasuresDropdown: testUtilsPivot.toggleMeasuresDropdown,
|
||||
reload: testUtilsPivot.reload,
|
||||
},
|
||||
fields: {
|
||||
many2one: {
|
||||
createAndEdit: testUtilsFields.clickM2OCreateAndEdit,
|
||||
clickOpenDropdown: testUtilsFields.clickOpenM2ODropdown,
|
||||
clickHighlightedItem: testUtilsFields.clickM2OHighlightedItem,
|
||||
clickItem: testUtilsFields.clickM2OItem,
|
||||
searchAndClickItem: testUtilsFields.searchAndClickM2OItem,
|
||||
},
|
||||
editInput: testUtilsFields.editInput,
|
||||
editSelect: testUtilsFields.editSelect,
|
||||
editAndTrigger: testUtilsFields.editAndTrigger,
|
||||
triggerKey: testUtilsFields.triggerKey,
|
||||
triggerKeydown: testUtilsFields.triggerKeydown,
|
||||
triggerKeyup: testUtilsFields.triggerKeyup,
|
||||
},
|
||||
file: {
|
||||
createFile: testUtilsFile.createFile,
|
||||
dragoverFile: testUtilsFile.dragoverFile,
|
||||
dropFile: testUtilsFile.dropFile,
|
||||
dropFiles: testUtilsFile.dropFiles,
|
||||
inputFiles: testUtilsFile.inputFiles,
|
||||
},
|
||||
|
||||
createComponent: testUtilsCreate.createComponent,
|
||||
createControlPanel: testUtilsCreate.createControlPanel,
|
||||
createAsyncView: testUtilsCreate.createView,
|
||||
createCalendarView: testUtilsCreate.createCalendarView,
|
||||
createView: testUtilsCreate.createView,
|
||||
createModel: testUtilsCreate.createModel,
|
||||
createParent: testUtilsCreate.createParent,
|
||||
makeTestPromise: makeTestPromise,
|
||||
makeTestPromiseWithAssert: makeTestPromiseWithAssert,
|
||||
nextMicrotaskTick: nextMicrotaskTick,
|
||||
nextTick: nextTick,
|
||||
owlCompatibilityExtraNextTick,
|
||||
prepareTarget: testUtilsCreate.prepareTarget,
|
||||
returnAfterNextAnimationFrame: testUtilsDom.returnAfterNextAnimationFrame,
|
||||
|
||||
// backward-compatibility
|
||||
addMockEnvironment: deprecated(testUtilsMock.addMockEnvironment, 'mock'),
|
||||
dragAndDrop: deprecated(testUtilsDom.dragAndDrop, 'dom'),
|
||||
getView: deprecated(testUtilsMock.getView, 'mock'),
|
||||
intercept: deprecated(testUtilsMock.intercept, 'mock'),
|
||||
openDatepicker: deprecated(testUtilsDom.openDatepicker, 'dom'),
|
||||
patch: deprecated(testUtilsMock.patch, 'mock'),
|
||||
patchDate: deprecated(testUtilsMock.patchDate, 'mock'),
|
||||
triggerKeypressEvent: deprecated(testUtilsDom.triggerKeypressEvent, 'dom'),
|
||||
triggerMouseEvent: deprecated(testUtilsDom.triggerMouseEvent, 'dom'),
|
||||
triggerPositionalMouseEvent: deprecated(testUtilsDom.triggerPositionalMouseEvent, 'dom'),
|
||||
unpatch: deprecated(testUtilsMock.unpatch, 'mock'),
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
odoo.define('web.test_utils_control_panel', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { click, findItem, getNode, triggerEvent } = require('web.test_utils_dom');
|
||||
const { editInput, editSelect, editAndTrigger } = require('web.test_utils_fields');
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Exported functions
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {(number|string)} menuFinder
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleMenu(el, menuFinder) {
|
||||
const menu = findItem(el, `.dropdown > button`, menuFinder);
|
||||
await click(menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {(number|string)} itemFinder
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleMenuItem(el, itemFinder) {
|
||||
const item = findItem(el, `.o_menu_item > a`, itemFinder);
|
||||
await click(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {(number|string)} itemFinder
|
||||
* @param {(number|string)} optionFinder
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleMenuItemOption(el, itemFinder, optionFinder) {
|
||||
const item = findItem(el, `.o_menu_item > a`, itemFinder);
|
||||
const option = findItem(item.parentNode, '.o_item_option > a', optionFinder);
|
||||
await click(option);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {(number|string)} itemFinder
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isItemSelected(el, itemFinder) {
|
||||
const item = findItem(el, `.o_menu_item > a`, itemFinder);
|
||||
return item.classList.contains('selected');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {(number|string)} itemuFinder
|
||||
* @param {(number|string)} optionFinder
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isOptionSelected(el, itemFinder, optionFinder) {
|
||||
const item = findItem(el, `.o_menu_item > a`, itemFinder);
|
||||
const option = findItem(item.parentNode, '.o_item_option > a', optionFinder);
|
||||
return option.classList.contains('selected');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getMenuItemTexts(el) {
|
||||
return [...getNode(el).querySelectorAll(`.dropdown ul .o_menu_item`)].map(
|
||||
e => e.innerText.trim()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {HTMLButtonElement[]}
|
||||
*/
|
||||
function getButtons(el) {
|
||||
return [...getNode(el).querySelector((`div.o_cp_bottom div.o_cp_buttons`)).children];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleFilterMenu(el) {
|
||||
await click(getNode(el).querySelector(`.o_filter_menu button`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleAddCustomFilter(el) {
|
||||
await click(getNode(el).querySelector(`button.o_add_custom_filter`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function applyFilter(el) {
|
||||
await click(getNode(el).querySelector(`div.o_add_filter_menu > button.o_apply_filter`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function addCondition(el) {
|
||||
await click(getNode(el).querySelector(`div.o_add_filter_menu > button.o_add_condition`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleGroupByMenu(el) {
|
||||
await click(getNode(el).querySelector(`.o_group_by_menu button`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleAddCustomGroup(el) {
|
||||
await click(getNode(el).querySelector(`span.o_add_custom_group_by`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {string} fieldName
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function selectGroup(el, fieldName) {
|
||||
await editSelect(
|
||||
getNode(el).querySelector(`select.o_group_by_selector`),
|
||||
fieldName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function applyGroup(el) {
|
||||
await click(getNode(el).querySelector(`div.o_add_group_by_menu > button.o_apply_group_by`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleFavoriteMenu(el) {
|
||||
await click(getNode(el).querySelector(`.o_favorite_menu button`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleSaveFavorite(el) {
|
||||
await click(getNode(el).querySelector(`.o_favorite_menu .o_add_favorite .dropdown-item`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {string} name
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function editFavoriteName(el, name) {
|
||||
await editInput(getNode(el).querySelector(`.o_favorite_menu .o_add_favorite input[type="text"]`), name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function saveFavorite(el) {
|
||||
await click(getNode(el).querySelector(`.o_favorite_menu .o_add_favorite button.o_save_favorite`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {(string|number)} favoriteFinder
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function deleteFavorite(el, favoriteFinder) {
|
||||
const favorite = findItem(el, `.o_favorite_menu .o_menu_item`, favoriteFinder);
|
||||
await click(favorite.querySelector('i.fa-trash'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleComparisonMenu(el) {
|
||||
await click(getNode(el).querySelector(`div.o_comparison_menu > button`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function getFacetTexts(el) {
|
||||
return [...getNode(el).querySelectorAll(`.o_searchview .o_searchview_facet`)].map(
|
||||
facet => facet.innerText.trim()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {(string|number)} facetFinder
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function removeFacet(el, facetFinder = 0) {
|
||||
const facet = findItem(el, `.o_searchview .o_searchview_facet`, facetFinder);
|
||||
await click(facet.querySelector('.o_facet_remove'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {string} value
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function editSearch(el, value) {
|
||||
await editInput(getNode(el).querySelector(`.o_searchview_input`), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function validateSearch(el) {
|
||||
await triggerEvent(
|
||||
getNode(el).querySelector(`.o_searchview_input`),
|
||||
'keydown', { key: 'Enter' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {string} [menuFinder="Action"]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleActionMenu(el, menuFinder = "Action") {
|
||||
const dropdown = findItem(el, `.o_cp_action_menus button`, menuFinder);
|
||||
await click(dropdown);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function pagerPrevious(el) {
|
||||
await click(getNode(el).querySelector(`.o_pager button.o_pager_previous`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function pagerNext(el) {
|
||||
await click(getNode(el).querySelector(`.o_pager button.o_pager_next`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {string}
|
||||
*/
|
||||
function getPagerValue(el) {
|
||||
const pagerValue = getNode(el).querySelector(`.o_pager_counter .o_pager_value`);
|
||||
switch (pagerValue.tagName) {
|
||||
case 'INPUT':
|
||||
return pagerValue.value;
|
||||
case 'SPAN':
|
||||
return pagerValue.innerText.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @returns {string}
|
||||
*/
|
||||
function getPagerSize(el) {
|
||||
return getNode(el).querySelector(`.o_pager_counter span.o_pager_limit`).innerText.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {string} value
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function setPagerValue(el, value) {
|
||||
let pagerValue = getNode(el).querySelector(`.o_pager_counter .o_pager_value`);
|
||||
if (pagerValue.tagName === 'SPAN') {
|
||||
await click(pagerValue);
|
||||
}
|
||||
pagerValue = getNode(el).querySelector(`.o_pager_counter input.o_pager_value`);
|
||||
if (!pagerValue) {
|
||||
throw new Error("Pager value is being edited and cannot be changed.");
|
||||
}
|
||||
await editAndTrigger(pagerValue, value, ['change', 'blur']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {string} viewType
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function switchView(el, viewType) {
|
||||
await click(getNode(el).querySelector(`button.o_switch_view.o_${viewType}`));
|
||||
}
|
||||
|
||||
return {
|
||||
// Generic interactions
|
||||
toggleMenu,
|
||||
toggleMenuItem,
|
||||
toggleMenuItemOption,
|
||||
isItemSelected,
|
||||
isOptionSelected,
|
||||
getMenuItemTexts,
|
||||
// Button interactions
|
||||
getButtons,
|
||||
// FilterMenu interactions
|
||||
toggleFilterMenu,
|
||||
toggleAddCustomFilter,
|
||||
applyFilter,
|
||||
addCondition,
|
||||
// GroupByMenu interactions
|
||||
toggleGroupByMenu,
|
||||
toggleAddCustomGroup,
|
||||
selectGroup,
|
||||
applyGroup,
|
||||
// FavoriteMenu interactions
|
||||
toggleFavoriteMenu,
|
||||
toggleSaveFavorite,
|
||||
editFavoriteName,
|
||||
saveFavorite,
|
||||
deleteFavorite,
|
||||
// ComparisonMenu interactions
|
||||
toggleComparisonMenu,
|
||||
// SearchBar interactions
|
||||
getFacetTexts,
|
||||
removeFacet,
|
||||
editSearch,
|
||||
validateSearch,
|
||||
// Action menus interactions
|
||||
toggleActionMenu,
|
||||
// Pager interactions
|
||||
pagerPrevious,
|
||||
pagerNext,
|
||||
getPagerValue,
|
||||
getPagerSize,
|
||||
setPagerValue,
|
||||
// View switcher
|
||||
switchView,
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
odoo.define('web.test_utils_create', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Create Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help creating mock widgets
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
const ActionMenus = require('web.ActionMenus');
|
||||
const concurrency = require('web.concurrency');
|
||||
const ControlPanel = require('web.ControlPanel');
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const dom = require('web.dom');
|
||||
const makeTestEnvironment = require('web.test_env');
|
||||
const ActionModel = require('web.ActionModel');
|
||||
const Registry = require('web.Registry');
|
||||
const testUtilsMock = require('web.test_utils_mock');
|
||||
const Widget = require('web.Widget');
|
||||
const { destroy, getFixture, mount, useChild } = require('@web/../tests/helpers/utils');
|
||||
const { registerCleanup } = require("@web/../tests/helpers/cleanup");
|
||||
const { LegacyComponent } = require("@web/legacy/legacy_component");
|
||||
|
||||
const { Component, onMounted, onWillStart, useState, xml } = owl;
|
||||
|
||||
/**
|
||||
* Similar as createView, but specific for calendar views. Some calendar
|
||||
* tests need to trigger positional clicks on the DOM produced by fullcalendar.
|
||||
* Those tests must use this helper with option positionalClicks set to true.
|
||||
* This will move the rendered calendar to the body (required to do positional
|
||||
* clicks), and wait for a setTimeout(0) before returning, because fullcalendar
|
||||
* makes the calendar scroll to 6:00 in a setTimeout(0), which might have an
|
||||
* impact according to where we want to trigger positional clicks.
|
||||
*
|
||||
* @param {Object} params @see createView
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.positionalClicks=false]
|
||||
* @returns {Promise<CalendarController>}
|
||||
*/
|
||||
async function createCalendarView(params, options) {
|
||||
const calendar = await createView(params);
|
||||
if (!options || !options.positionalClicks) {
|
||||
return calendar;
|
||||
}
|
||||
const viewElements = [...document.getElementById('qunit-fixture').children];
|
||||
// prepend reset the scrollTop to zero so we restore it manually
|
||||
let fcScroller = document.querySelector('.fc-scroller');
|
||||
const scrollPosition = fcScroller.scrollTop;
|
||||
viewElements.forEach(el => document.body.prepend(el));
|
||||
fcScroller = document.querySelector('.fc-scroller');
|
||||
fcScroller.scrollTop = scrollPosition;
|
||||
|
||||
const destroy = calendar.destroy;
|
||||
calendar.destroy = () => {
|
||||
viewElements.forEach(el => el.remove());
|
||||
destroy();
|
||||
};
|
||||
await concurrency.delay(0);
|
||||
return calendar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple component environment with a basic Parent component, an
|
||||
* extensible env and a mocked server. The returned value is the instance of
|
||||
* the given constructor.
|
||||
* @param {class} constructor Component class to instantiate
|
||||
* @param {Object} [params = {}]
|
||||
* @param {boolean} [params.debug]
|
||||
* @param {Object} [params.env]
|
||||
* @param {Object} [params.intercepts] object in which the keys represent the
|
||||
* intercepted event names and the values are their callbacks.
|
||||
* @param {Object} [params.props]
|
||||
* @returns {Promise<Component>} instance of `constructor`
|
||||
*/
|
||||
async function createComponent(constructor, params = {}) {
|
||||
if (!constructor) {
|
||||
throw new Error(`Missing argument "constructor".`);
|
||||
}
|
||||
if (!(constructor.prototype instanceof Component)) {
|
||||
throw new Error(`Argument "constructor" must be an Owl Component.`);
|
||||
}
|
||||
const cleanUp = await testUtilsMock.addMockEnvironmentOwl(Component, params);
|
||||
class Parent extends LegacyComponent {
|
||||
setup() {
|
||||
this.Component = constructor;
|
||||
this.state = useState(params.props || {});
|
||||
for (const eventName in params.intercepts || {}) {
|
||||
useListener(eventName, params.intercepts[eventName]);
|
||||
}
|
||||
useChild();
|
||||
}
|
||||
}
|
||||
Parent.template = xml`<t t-component="Component" t-props="state"/>`;
|
||||
|
||||
const target = getFixture();
|
||||
const env = Component.env;
|
||||
const parent = await mount(Parent, target, { env });
|
||||
registerCleanup(cleanUp);
|
||||
registerCleanup(() => {
|
||||
destroy(parent);
|
||||
});
|
||||
return parent.child;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Control Panel instance, with an extensible environment and
|
||||
* its related Control Panel Model. Event interception is done through
|
||||
* params['get-controller-query-params'] and params.search, for the two
|
||||
* available event handlers respectively.
|
||||
* @param {Object} [params={}]
|
||||
* @param {Object} [params.cpProps]
|
||||
* @param {Object} [params.cpModelConfig]
|
||||
* @param {boolean} [params.debug]
|
||||
* @param {Object} [params.env]
|
||||
* @returns {Object} useful control panel testing elements:
|
||||
* - controlPanel: the control panel instance
|
||||
* - el: the control panel HTML element
|
||||
* - helpers: a suite of bound helpers (see above functions for all
|
||||
* available helpers)
|
||||
*/
|
||||
async function createControlPanel(params = {}) {
|
||||
const env = makeTestEnvironment(params.env || {});
|
||||
const props = Object.assign({
|
||||
action: {},
|
||||
fields: {},
|
||||
}, params.cpProps);
|
||||
const globalConfig = Object.assign({
|
||||
context: {},
|
||||
domain: [],
|
||||
}, params.cpModelConfig);
|
||||
|
||||
if (globalConfig.arch && globalConfig.fields) {
|
||||
const model = "__mockmodel__";
|
||||
const serverParams = {
|
||||
model,
|
||||
data: { [model]: { fields: globalConfig.fields, records: [] } },
|
||||
};
|
||||
const mockServer = await testUtilsMock.addMockEnvironment(
|
||||
new Widget(),
|
||||
serverParams,
|
||||
);
|
||||
const { arch } = testUtilsMock.getView(mockServer, {
|
||||
arch: globalConfig.arch,
|
||||
fields: globalConfig.fields,
|
||||
model,
|
||||
viewOptions: { context: globalConfig.context },
|
||||
});
|
||||
Object.assign(globalConfig, { arch });
|
||||
}
|
||||
|
||||
globalConfig.env = env;
|
||||
const archs = (globalConfig.arch && { search: globalConfig.arch, }) || {};
|
||||
const { ControlPanel: controlPanelInfo, } = ActionModel.extractArchInfo(archs);
|
||||
const extensions = {
|
||||
ControlPanel: { archNodes: controlPanelInfo.children, },
|
||||
};
|
||||
|
||||
class Parent extends LegacyComponent {
|
||||
setup() {
|
||||
this.searchModel = new ActionModel(extensions, globalConfig);
|
||||
this.state = useState(props);
|
||||
useChild();
|
||||
onWillStart(async () => {
|
||||
await this.searchModel.load();
|
||||
});
|
||||
onMounted(() => {
|
||||
if (params['get-controller-query-params']) {
|
||||
this.searchModel.on('get-controller-query-params', this,
|
||||
params['get-controller-query-params']);
|
||||
}
|
||||
if (params.search) {
|
||||
this.searchModel.on('search', this, params.search);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Parent.components = { ControlPanel };
|
||||
Parent.template = xml`
|
||||
<ControlPanel
|
||||
t-props="state"
|
||||
searchModel="searchModel"
|
||||
/>`;
|
||||
|
||||
const target = getFixture();
|
||||
const parent = await mount(Parent, target, { env });
|
||||
const controlPanel = parent.child;
|
||||
controlPanel.getQuery = () => parent.searchModel.get("query");
|
||||
registerCleanup(() => {
|
||||
destroy(parent);
|
||||
});
|
||||
return controlPanel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a model from given parameters.
|
||||
*
|
||||
* @param {Object} params This object will be given to addMockEnvironment, so
|
||||
* any parameters from that method applies
|
||||
* @param {Class} params.Model the model class to use
|
||||
* @returns {Model}
|
||||
*/
|
||||
async function createModel(params) {
|
||||
const widget = new Widget();
|
||||
|
||||
const model = new params.Model(widget, params);
|
||||
|
||||
await testUtilsMock.addMockEnvironment(widget, params);
|
||||
|
||||
// override the model's 'destroy' so that it calls 'destroy' on the widget
|
||||
// instead, as the widget is the parent of the model and the mockServer.
|
||||
model.destroy = function () {
|
||||
// remove the override to properly destroy the model when it will be
|
||||
// called the second time (by its parent)
|
||||
delete model.destroy;
|
||||
widget.destroy();
|
||||
};
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a widget parent from given parameters.
|
||||
*
|
||||
* @param {Object} params This object will be given to addMockEnvironment, so
|
||||
* any parameters from that method applies
|
||||
* @returns {Promise<Widget>}
|
||||
*/
|
||||
async function createParent(params) {
|
||||
const widget = new Widget();
|
||||
await testUtilsMock.addMockEnvironment(widget, params);
|
||||
return widget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a view from various parameters. Here, a view means a javascript
|
||||
* instance of an AbstractView class, such as a form view, a list view or a
|
||||
* kanban view.
|
||||
*
|
||||
* It returns the instance of the view, properly created, with all rpcs going
|
||||
* through a mock method using the data object as source, and already loaded/
|
||||
* started.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.arch the xml (arch) of the view to be instantiated
|
||||
* @param {any[]} [params.domain] the initial domain for the view
|
||||
* @param {Object} [params.context] the initial context for the view
|
||||
* @param {string[]} [params.groupBy] the initial groupBy for the view
|
||||
* @param {Object[]} [params.favoriteFilters] the favorite filters one would like to have at initialization
|
||||
* @param {integer} [params.fieldDebounce=0] the debounce value to use for the
|
||||
* duration of the test.
|
||||
* @param {AbstractView} params.View the class that will be instantiated
|
||||
* @param {string} params.model a model name, will be given to the view
|
||||
* @param {Object} params.intercepts an object with event names as key, and
|
||||
* callback as value. Each key,value will be used to intercept the event.
|
||||
* Note that this is particularly useful if you want to intercept events going
|
||||
* up in the init process of the view, because there are no other way to do it
|
||||
* after this method returns
|
||||
* @param {Boolean} [params.doNotDisableAHref=false] will not preventDefault on the A elements of the view if true.
|
||||
* Default is false.
|
||||
* @param {Boolean} [params.touchScreen=false] will add the o_touch_device to the webclient (flag used to define a
|
||||
* device with a touch screen. Default value is false
|
||||
* @returns {Promise<AbstractController>} the instance of the view
|
||||
*/
|
||||
async function createView(params) {
|
||||
const target = prepareTarget(params.debug);
|
||||
const widget = new Widget();
|
||||
// reproduce the DOM environment of views
|
||||
const webClient = Object.assign(document.createElement('div'), {
|
||||
className: params.touchScreen ? 'o_web_client o_touch_device' : 'o_web_client',
|
||||
});
|
||||
const actionManager = Object.assign(document.createElement('div'), {
|
||||
className: 'o_action_manager',
|
||||
});
|
||||
const dialogContainer = Object.assign(document.createElement('div'), {
|
||||
className: 'o_dialog_container',
|
||||
});
|
||||
target.prepend(webClient);
|
||||
webClient.append(actionManager);
|
||||
webClient.append(dialogContainer);
|
||||
|
||||
// add mock environment: mock server, session, fieldviewget, ...
|
||||
const mockServer = await testUtilsMock.addMockEnvironment(widget, params);
|
||||
const viewInfo = testUtilsMock.getView(mockServer, params);
|
||||
|
||||
params.server = mockServer;
|
||||
|
||||
// create the view
|
||||
const View = params.View;
|
||||
const modelName = params.model || 'foo';
|
||||
const defaultAction = {
|
||||
res_model: modelName,
|
||||
context: {},
|
||||
type: 'ir.actions.act_window',
|
||||
};
|
||||
const viewOptions = Object.assign({
|
||||
action: Object.assign(defaultAction, params.action),
|
||||
view: { ...viewInfo, fields: mockServer.fieldsGet(params.model) },
|
||||
modelName: modelName,
|
||||
ids: 'res_id' in params ? [params.res_id] : undefined,
|
||||
currentId: 'res_id' in params ? params.res_id : undefined,
|
||||
domain: params.domain || [],
|
||||
context: params.context || {},
|
||||
hasActionMenus: false,
|
||||
}, params.viewOptions);
|
||||
// patch the View to handle the groupBy given in params, as we can't give it
|
||||
// in init (unlike the domain and context which can be set in the action)
|
||||
testUtilsMock.patch(View, {
|
||||
_updateMVCParams() {
|
||||
this._super(...arguments);
|
||||
this.loadParams.groupedBy = params.groupBy || viewOptions.groupBy || [];
|
||||
testUtilsMock.unpatch(View);
|
||||
},
|
||||
});
|
||||
if ('hasSelectors' in params) {
|
||||
viewOptions.hasSelectors = params.hasSelectors;
|
||||
}
|
||||
|
||||
let view;
|
||||
if (viewInfo.type === 'controlpanel' || viewInfo.type === 'search') {
|
||||
// TODO: probably needs to create an helper just for that
|
||||
view = new params.View({ viewInfo, modelName });
|
||||
} else {
|
||||
viewOptions.controlPanelFieldsView = Object.assign(testUtilsMock.getView(mockServer, {
|
||||
arch: params.archs && params.archs[params.model + ',false,search'] || '<search/>',
|
||||
fields: viewInfo.fields,
|
||||
model: params.model,
|
||||
}), { favoriteFilters: params.favoriteFilters });
|
||||
|
||||
view = new params.View(viewInfo, viewOptions);
|
||||
}
|
||||
|
||||
if (params.interceptsPropagate) {
|
||||
for (const name in params.interceptsPropagate) {
|
||||
testUtilsMock.intercept(widget, name, params.interceptsPropagate[name], true);
|
||||
}
|
||||
}
|
||||
|
||||
// Override the ActionMenus registry unless told otherwise.
|
||||
let actionMenusRegistry = ActionMenus.registry;
|
||||
if (params.actionMenusRegistry !== true) {
|
||||
ActionMenus.registry = new Registry();
|
||||
}
|
||||
|
||||
const viewController = await view.getController(widget);
|
||||
// override the view's 'destroy' so that it calls 'destroy' on the widget
|
||||
// instead, as the widget is the parent of the view and the mockServer.
|
||||
viewController.__destroy = viewController.destroy;
|
||||
viewController.destroy = function () {
|
||||
// remove the override to properly destroy the viewController and its children
|
||||
// when it will be called the second time (by its parent)
|
||||
delete viewController.destroy;
|
||||
widget.destroy();
|
||||
webClient.remove();
|
||||
if (params.actionMenusRegistry !== true) {
|
||||
ActionMenus.registry = actionMenusRegistry;
|
||||
}
|
||||
};
|
||||
|
||||
// render the viewController in a fragment as they must be able to render correctly
|
||||
// without being in the DOM
|
||||
const fragment = document.createDocumentFragment();
|
||||
await viewController.appendTo(fragment);
|
||||
dom.prepend(actionManager, fragment, {
|
||||
callbacks: [{ widget: viewController }],
|
||||
in_DOM: true,
|
||||
});
|
||||
|
||||
if (!params.doNotDisableAHref) {
|
||||
[...viewController.el.getElementsByTagName('A')].forEach(elem => {
|
||||
elem.addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
});
|
||||
});
|
||||
}
|
||||
return viewController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target (fixture or body) of the document and adds event listeners
|
||||
* to intercept custom or DOM events.
|
||||
*
|
||||
* @param {boolean} [debug=false] if true, the widget will be appended in
|
||||
* the DOM. Also, RPCs and uncaught OdooEvent will be logged
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function prepareTarget(debug = false) {
|
||||
document.body.classList.toggle('debug', debug);
|
||||
return debug ? document.body : document.getElementById('qunit-fixture');
|
||||
}
|
||||
|
||||
return {
|
||||
createCalendarView,
|
||||
createComponent,
|
||||
createControlPanel,
|
||||
createModel,
|
||||
createParent,
|
||||
createView,
|
||||
prepareTarget,
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,610 @@
|
|||
odoo.define('web.test_utils_dom', function (require) {
|
||||
"use strict";
|
||||
|
||||
const concurrency = require('web.concurrency');
|
||||
const Widget = require('web.Widget');
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
/**
|
||||
* DOM Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help simulate DOM events.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Private functions
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
// TriggerEvent helpers
|
||||
const keyboardEventBubble = args => Object.assign({}, args, { bubbles: true, keyCode: args.which });
|
||||
const mouseEventMapping = args => Object.assign({}, args, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: args ? args.clientX || args.pageX : undefined,
|
||||
clientY: args ? args.clientY || args.pageY : undefined,
|
||||
view: window,
|
||||
});
|
||||
const mouseEventNoBubble = args => Object.assign({}, args, {
|
||||
bubbles: false,
|
||||
cancelable: false,
|
||||
clientX: args ? args.clientX || args.pageX : undefined,
|
||||
clientY: args ? args.clientY || args.pageY : undefined,
|
||||
view: window,
|
||||
});
|
||||
const touchEventMapping = args => Object.assign({}, args, {
|
||||
cancelable: true,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
view: window,
|
||||
rotation: 0.0,
|
||||
zoom: 1.0,
|
||||
});
|
||||
const touchEventCancelMapping = args => Object.assign({}, touchEventMapping(args), {
|
||||
cancelable: false,
|
||||
});
|
||||
const noBubble = args => Object.assign({}, args, { bubbles: false });
|
||||
const onlyBubble = args => Object.assign({}, args, { bubbles: true });
|
||||
// TriggerEvent constructor/args processor mapping
|
||||
const EVENT_TYPES = {
|
||||
auxclick: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
click: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
contextmenu: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
dblclick: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
mousedown: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
mouseup: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
|
||||
mousemove: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
mouseenter: { constructor: MouseEvent, processParameters: mouseEventNoBubble },
|
||||
mouseleave: { constructor: MouseEvent, processParameters: mouseEventNoBubble },
|
||||
mouseover: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
mouseout: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
|
||||
focus: { constructor: FocusEvent, processParameters: noBubble },
|
||||
focusin: { constructor: FocusEvent, processParameters: onlyBubble },
|
||||
blur: { constructor: FocusEvent, processParameters: noBubble },
|
||||
|
||||
cut: { constructor: ClipboardEvent, processParameters: onlyBubble },
|
||||
copy: { constructor: ClipboardEvent, processParameters: onlyBubble },
|
||||
paste: { constructor: ClipboardEvent, processParameters: onlyBubble },
|
||||
|
||||
keydown: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
|
||||
keypress: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
|
||||
keyup: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
|
||||
|
||||
drag: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
dragend: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
dragenter: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
dragstart: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
dragleave: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
dragover: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
drop: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
|
||||
input: { constructor: InputEvent, processParameters: onlyBubble },
|
||||
|
||||
compositionstart: { constructor: CompositionEvent, processParameters: onlyBubble },
|
||||
compositionend: { constructor: CompositionEvent, processParameters: onlyBubble },
|
||||
};
|
||||
|
||||
if (typeof TouchEvent === 'function') {
|
||||
Object.assign(EVENT_TYPES, {
|
||||
touchstart: {constructor: TouchEvent, processParameters: touchEventMapping},
|
||||
touchend: {constructor: TouchEvent, processParameters: touchEventMapping},
|
||||
touchmove: {constructor: TouchEvent, processParameters: touchEventMapping},
|
||||
touchcancel: {constructor: TouchEvent, processParameters: touchEventCancelMapping},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an object is an instance of EventTarget.
|
||||
*
|
||||
* @param {Object} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isEventTarget(node) {
|
||||
if (!node) {
|
||||
throw new Error(`Provided node is ${node}.`);
|
||||
}
|
||||
if (node instanceof window.top.EventTarget) {
|
||||
return true;
|
||||
}
|
||||
const contextWindow = node.defaultView || // document
|
||||
(node.ownerDocument && node.ownerDocument.defaultView); // iframe node
|
||||
return contextWindow && node instanceof contextWindow.EventTarget;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Public functions
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Click on a specified element. If the option first or last is not specified,
|
||||
* this method also check the unicity and the visibility of the target.
|
||||
*
|
||||
* @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector)
|
||||
* @param {Object} [options={}] click options
|
||||
* @param {boolean} [options.allowInvisible=false] if true, clicks on the
|
||||
* element event if it is invisible
|
||||
* @param {boolean} [options.first=false] if true, clicks on the first element
|
||||
* @param {boolean} [options.last=false] if true, clicks on the last element
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function click(el, options = {}) {
|
||||
let matches, target;
|
||||
let selectorMsg = "";
|
||||
if (typeof el === 'string') {
|
||||
el = $(el);
|
||||
}
|
||||
if (el.disabled || (el instanceof jQuery && el.get(0).disabled)) {
|
||||
throw new Error("Can't click on a disabled button");
|
||||
}
|
||||
if (_isEventTarget(el)) {
|
||||
// EventTarget
|
||||
matches = [el];
|
||||
} else {
|
||||
// Any other iterable object containing EventTarget objects (jQuery, HTMLCollection, etc.)
|
||||
matches = [...el];
|
||||
}
|
||||
|
||||
const validMatches = options.allowInvisible ?
|
||||
matches : matches.filter(t => $(t).is(':visible'));
|
||||
|
||||
if (options.first) {
|
||||
if (validMatches.length === 1) {
|
||||
throw new Error(`There should be more than one visible target ${selectorMsg}. If` +
|
||||
' you are sure that there is exactly one target, please use the ' +
|
||||
'click function instead of the clickFirst function');
|
||||
}
|
||||
target = validMatches[0];
|
||||
} else if (options.last) {
|
||||
if (validMatches.length === 1) {
|
||||
throw new Error(`There should be more than one visible target ${selectorMsg}. If` +
|
||||
' you are sure that there is exactly one target, please use the ' +
|
||||
'click function instead of the clickLast function');
|
||||
}
|
||||
target = validMatches[validMatches.length - 1];
|
||||
} else if (validMatches.length !== 1) {
|
||||
throw new Error(`Found ${validMatches.length} elements to click on, instead of 1 ${selectorMsg}`);
|
||||
} else {
|
||||
target = validMatches[0];
|
||||
}
|
||||
if (validMatches.length === 0 && matches.length > 0) {
|
||||
throw new Error(`Element to click on is not visible ${selectorMsg}`);
|
||||
}
|
||||
if (target.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return triggerEvent(target, 'click');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the first element of a list of elements. Note that if the list has
|
||||
* only one visible element, we trigger an error. In that case, it is better to
|
||||
* use the click helper instead.
|
||||
*
|
||||
* @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector)
|
||||
* @param {boolean} [options={}] click options
|
||||
* @param {boolean} [options.allowInvisible=false] if true, clicks on the
|
||||
* element event if it is invisible
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function clickFirst(el, options) {
|
||||
return click(el, Object.assign({}, options, { first: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the last element of a list of elements. Note that if the list has
|
||||
* only one visible element, we trigger an error. In that case, it is better to
|
||||
* use the click helper instead.
|
||||
*
|
||||
* @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector)
|
||||
* @param {boolean} [options={}] click options
|
||||
* @param {boolean} [options.allowInvisible=false] if true, clicks on the
|
||||
* element event if it is invisible
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function clickLast(el, options) {
|
||||
return click(el, Object.assign({}, options, { last: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a drag and drop operation between 2 jquery nodes: $el and $to.
|
||||
* This is a crude simulation, with only the mousedown, mousemove and mouseup
|
||||
* events, but it is enough to help test drag and drop operations with jqueryUI
|
||||
* sortable.
|
||||
*
|
||||
* @todo: remove the withTrailingClick option when the jquery update branch is
|
||||
* merged. This is not the default as of now, because handlers are triggered
|
||||
* synchronously, which is not the same as the 'reality'.
|
||||
*
|
||||
* @param {jQuery|EventTarget} $el
|
||||
* @param {jQuery|EventTarget} $to
|
||||
* @param {Object} [options]
|
||||
* @param {string|Object} [options.position='center'] target position:
|
||||
* can either be one of {'top', 'bottom', 'left', 'right'} or
|
||||
* an object with two attributes (top and left))
|
||||
* @param {boolean} [options.disableDrop=false] whether to trigger the drop action
|
||||
* @param {boolean} [options.continueMove=false] whether to trigger the
|
||||
* mousedown action (will only work after another call of this function with
|
||||
* without this option)
|
||||
* @param {boolean} [options.withTrailingClick=false] if true, this utility
|
||||
* function will also trigger a click on the target after the mouseup event
|
||||
* (this is actually what happens when a drag and drop operation is done)
|
||||
* @param {jQuery|EventTarget} [options.mouseenterTarget=undefined] target of the mouseenter event
|
||||
* @param {jQuery|EventTarget} [options.mousedownTarget=undefined] target of the mousedown event
|
||||
* @param {jQuery|EventTarget} [options.mousemoveTarget=undefined] target of the mousemove event
|
||||
* @param {jQuery|EventTarget} [options.mouseupTarget=undefined] target of the mouseup event
|
||||
* @param {jQuery|EventTarget} [options.ctrlKey=undefined] if the ctrl key should be considered pressed at the time of mouseup
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function dragAndDrop($el, $to, options) {
|
||||
let el = null;
|
||||
if (_isEventTarget($el)) {
|
||||
el = $el;
|
||||
$el = $(el);
|
||||
}
|
||||
if (_isEventTarget($to)) {
|
||||
$to = $($to);
|
||||
}
|
||||
options = options || {};
|
||||
const position = options.position || 'center';
|
||||
const elementCenter = $el.offset();
|
||||
const toOffset = $to.offset();
|
||||
|
||||
if (typeof position === 'object') {
|
||||
toOffset.top += position.top + 1;
|
||||
toOffset.left += position.left + 1;
|
||||
} else {
|
||||
toOffset.top += $to.outerHeight() / 2;
|
||||
toOffset.left += $to.outerWidth() / 2;
|
||||
const vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1;
|
||||
if (position === 'top') {
|
||||
toOffset.top -= $to.outerHeight() / 2 + vertical_offset;
|
||||
} else if (position === 'bottom') {
|
||||
toOffset.top += $to.outerHeight() / 2 - vertical_offset;
|
||||
} else if (position === 'left') {
|
||||
toOffset.left -= $to.outerWidth() / 2;
|
||||
} else if (position === 'right') {
|
||||
toOffset.left += $to.outerWidth() / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if ($to[0].ownerDocument !== document) {
|
||||
// we are in an iframe
|
||||
const bound = $('iframe')[0].getBoundingClientRect();
|
||||
toOffset.left += bound.left;
|
||||
toOffset.top += bound.top;
|
||||
}
|
||||
await triggerEvent(options.mouseenterTarget || el || $el, 'mouseover', {}, true);
|
||||
if (!(options.continueMove)) {
|
||||
elementCenter.left += $el.outerWidth() / 2;
|
||||
elementCenter.top += $el.outerHeight() / 2;
|
||||
|
||||
await triggerEvent(options.mousedownTarget || el || $el, 'mousedown', {
|
||||
which: 1,
|
||||
pageX: elementCenter.left,
|
||||
pageY: elementCenter.top
|
||||
}, true);
|
||||
}
|
||||
await triggerEvent(options.mousemoveTarget || el || $el, 'mousemove', {
|
||||
which: 1,
|
||||
pageX: toOffset.left,
|
||||
pageY: toOffset.top
|
||||
}, true);
|
||||
|
||||
if (!options.disableDrop) {
|
||||
await triggerEvent(options.mouseupTarget || el || $el, 'mouseup', {
|
||||
which: 1,
|
||||
pageX: toOffset.left,
|
||||
pageY: toOffset.top,
|
||||
ctrlKey: options.ctrlKey,
|
||||
}, true);
|
||||
if (options.withTrailingClick) {
|
||||
await triggerEvent(options.mouseupTarget || el || $el, 'click', {}, true);
|
||||
}
|
||||
} else {
|
||||
// It's impossible to drag another element when one is already
|
||||
// being dragged. So it's necessary to finish the drop when the test is
|
||||
// over otherwise it's impossible for the next tests to drag and
|
||||
// drop elements.
|
||||
$el.on('remove', function () {
|
||||
triggerEvent($el, 'mouseup', {}, true);
|
||||
});
|
||||
}
|
||||
return returnAfterNextAnimationFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to retrieve a distinct item from a collection of elements defined
|
||||
* by the given "selector" string. It can either be the index of the item or its
|
||||
* inner text.
|
||||
* @param {Element} el
|
||||
* @param {string} selector
|
||||
* @param {number | string} [elFinder=0]
|
||||
* @returns {Element | null}
|
||||
*/
|
||||
function findItem(el, selector, elFinder = 0) {
|
||||
const elements = [...getNode(el).querySelectorAll(selector)];
|
||||
if (!elements.length) {
|
||||
throw new Error(`No element found with selector "${selector}".`);
|
||||
}
|
||||
switch (typeof elFinder) {
|
||||
case "number": {
|
||||
const match = elements[elFinder];
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`No element with selector "${selector}" at index ${elFinder}.`
|
||||
);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
case "string": {
|
||||
const match = elements.find(
|
||||
(el) => el.innerText.trim().toLowerCase() === elFinder.toLowerCase()
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`No element with selector "${selector}" containing "${elFinder}".
|
||||
`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
default: throw new Error(
|
||||
`Invalid provided element finder: must be a number|string|function.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function used to extract an HTML EventTarget element from a given
|
||||
* target. The extracted element will depend on the target type:
|
||||
* - Component|Widget -> el
|
||||
* - jQuery -> associated element (must have 1)
|
||||
* - HTMLCollection (or similar) -> first element (must have 1)
|
||||
* - string -> result of document.querySelector with string
|
||||
* - else -> as is
|
||||
* @private
|
||||
* @param {(Component|Widget|jQuery|HTMLCollection|HTMLElement|string)} target
|
||||
* @returns {EventTarget}
|
||||
*/
|
||||
function getNode(target) {
|
||||
let nodes;
|
||||
if (target instanceof Component || target instanceof Widget) {
|
||||
nodes = [target.el];
|
||||
} else if (typeof target === 'string') {
|
||||
nodes = document.querySelectorAll(target);
|
||||
} else if (target === jQuery) { // jQuery (or $)
|
||||
nodes = [document.body];
|
||||
} else if (target.length) { // jQuery instance, HTMLCollection or array
|
||||
nodes = target;
|
||||
} else {
|
||||
nodes = [target];
|
||||
}
|
||||
if (nodes.length !== 1) {
|
||||
throw new Error(`Found ${nodes.length} nodes instead of 1.`);
|
||||
}
|
||||
const node = nodes[0];
|
||||
if (!node) {
|
||||
throw new Error(`Expected a node and got ${node}.`);
|
||||
}
|
||||
if (!_isEventTarget(node)) {
|
||||
throw new Error(`Expected node to be an instance of EventTarget and got ${node.constructor.name}.`);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the datepicker of a given element.
|
||||
*
|
||||
* @param {jQuery} $datepickerEl element to which a datepicker is attached
|
||||
*/
|
||||
async function openDatepicker($datepickerEl) {
|
||||
return click($datepickerEl.find('.o_datepicker_input'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that will be resolved after the nextAnimationFrame after
|
||||
* the next tick
|
||||
*
|
||||
* This is useful to guarantee that OWL has had the time to render
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function returnAfterNextAnimationFrame() {
|
||||
await concurrency.delay(0);
|
||||
await new Promise(resolve => {
|
||||
window.requestAnimationFrame(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an event on the specified target.
|
||||
* This function will dispatch a native event to an EventTarget or a
|
||||
* jQuery event to a jQuery object. This behaviour can be overridden by the
|
||||
* jquery option.
|
||||
*
|
||||
* @param {EventTarget|EventTarget[]} el
|
||||
* @param {string} eventType event type
|
||||
* @param {Object} [eventAttrs] event attributes
|
||||
* on a jQuery element with the `$.fn.trigger` function
|
||||
* @param {Boolean} [fast=false] true if the trigger event have to wait for a single tick instead of waiting for the next animation frame
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function triggerEvent(el, eventType, eventAttrs = {}, fast = false) {
|
||||
let matches;
|
||||
let selectorMsg = "";
|
||||
if (_isEventTarget(el)) {
|
||||
matches = [el];
|
||||
} else {
|
||||
matches = [...el];
|
||||
}
|
||||
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Found ${matches.length} elements to trigger "${eventType}" on, instead of 1 ${selectorMsg}`);
|
||||
}
|
||||
|
||||
const target = matches[0];
|
||||
let event;
|
||||
|
||||
if (!EVENT_TYPES[eventType] && !EVENT_TYPES[eventType.type]) {
|
||||
event = new Event(eventType, Object.assign({}, eventAttrs, { bubbles: true }));
|
||||
} else {
|
||||
if (typeof eventType === "object") {
|
||||
const { constructor, processParameters } = EVENT_TYPES[eventType.type];
|
||||
const eventParameters = processParameters(eventType);
|
||||
event = new constructor(eventType.type, eventParameters);
|
||||
} else {
|
||||
const { constructor, processParameters } = EVENT_TYPES[eventType];
|
||||
event = new constructor(eventType, processParameters(eventAttrs));
|
||||
}
|
||||
}
|
||||
target.dispatchEvent(event);
|
||||
return fast ? undefined : returnAfterNextAnimationFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger multiple events on the specified element.
|
||||
*
|
||||
* @param {EventTarget|EventTarget[]} el
|
||||
* @param {string[]} events the events you want to trigger
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function triggerEvents(el, events) {
|
||||
if (el instanceof jQuery) {
|
||||
if (el.length !== 1) {
|
||||
throw new Error(`target has length ${el.length} instead of 1`);
|
||||
}
|
||||
}
|
||||
if (typeof events === 'string') {
|
||||
events = [events];
|
||||
}
|
||||
|
||||
for (let e = 0; e < events.length; e++) {
|
||||
await triggerEvent(el, events[e]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a keypress event for a given character
|
||||
*
|
||||
* @param {string} char the character, or 'ENTER'
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function triggerKeypressEvent(char) {
|
||||
let keycode;
|
||||
if (char === 'Enter') {
|
||||
keycode = $.ui.keyCode.ENTER;
|
||||
} else if (char === "Tab") {
|
||||
keycode = $.ui.keyCode.TAB;
|
||||
} else {
|
||||
keycode = char.charCodeAt(0);
|
||||
}
|
||||
return triggerEvent(document.body, 'keypress', {
|
||||
key: char,
|
||||
keyCode: keycode,
|
||||
which: keycode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* simulate a mouse event with a custom event who add the item position. This is
|
||||
* sometimes necessary because the basic way to trigger an event (such as
|
||||
* $el.trigger('mousemove')); ) is too crude for some uses.
|
||||
*
|
||||
* @param {jQuery|EventTarget} $el
|
||||
* @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function triggerMouseEvent($el, type) {
|
||||
const el = $el instanceof jQuery ? $el[0] : $el;
|
||||
if (!el) {
|
||||
throw new Error(`no target found to trigger MouseEvent`);
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
// try to click around the center of the element, biased to the bottom
|
||||
// right as chrome messes up when clicking on the top-left corner...
|
||||
const left = rect.x + Math.ceil(rect.width / 2);
|
||||
const top = rect.y + Math.ceil(rect.height / 2);
|
||||
return triggerEvent(el, type, {which: 1, clientX: left, clientY: top});
|
||||
}
|
||||
|
||||
/**
|
||||
* simulate a mouse event with a custom event on a position x and y. This is
|
||||
* sometimes necessary because the basic way to trigger an event (such as
|
||||
* $el.trigger('mousemove')); ) is too crude for some uses.
|
||||
*
|
||||
* @param {integer} x
|
||||
* @param {integer} y
|
||||
* @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
async function triggerPositionalMouseEvent(x, y, type) {
|
||||
const ev = document.createEvent("MouseEvent");
|
||||
const el = document.elementFromPoint(x, y);
|
||||
ev.initMouseEvent(
|
||||
type,
|
||||
true /* bubble */,
|
||||
true /* cancelable */,
|
||||
window, null,
|
||||
x, y, x, y, /* coordinates */
|
||||
false, false, false, false, /* modifier keys */
|
||||
0 /*left button*/, null
|
||||
);
|
||||
el.dispatchEvent(ev);
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a "TAP" (touch) event with a custom position x and y.
|
||||
*
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
async function triggerPositionalTapEvents(x, y) {
|
||||
const element = document.elementFromPoint(x, y);
|
||||
const touch = new Touch({
|
||||
identifier: 0,
|
||||
target: element,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pageX: x,
|
||||
pageY: y,
|
||||
});
|
||||
await triggerEvent(element, 'touchstart', {
|
||||
touches: [touch],
|
||||
targetTouches: [touch],
|
||||
changedTouches: [touch],
|
||||
});
|
||||
await triggerEvent(element, 'touchmove', {
|
||||
touches: [touch],
|
||||
targetTouches: [touch],
|
||||
changedTouches: [touch],
|
||||
});
|
||||
await triggerEvent(element, 'touchend', {
|
||||
changedTouches: [touch],
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
click,
|
||||
clickFirst,
|
||||
clickLast,
|
||||
dragAndDrop,
|
||||
findItem,
|
||||
getNode,
|
||||
openDatepicker,
|
||||
returnAfterNextAnimationFrame,
|
||||
triggerEvent,
|
||||
triggerEvents,
|
||||
triggerKeypressEvent,
|
||||
triggerMouseEvent,
|
||||
triggerPositionalMouseEvent,
|
||||
triggerPositionalTapEvents,
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
odoo.define('web.test_utils_fields', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Field Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help testing field widgets.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
const testUtilsDom = require('web.test_utils_dom');
|
||||
|
||||
const ARROW_KEYS_MAPPING = {
|
||||
down: 'ArrowDown',
|
||||
left: 'ArrowLeft',
|
||||
right: 'ArrowRight',
|
||||
up: 'ArrowUp',
|
||||
};
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Public functions
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Autofills the input of a many2one field and clicks on the "Create and Edit" option.
|
||||
*
|
||||
* @param {string} fieldName
|
||||
* @param {string} text Used as default value for the record name
|
||||
* @see clickM2OItem
|
||||
*/
|
||||
async function clickM2OCreateAndEdit(fieldName, text = "ABC") {
|
||||
await clickOpenM2ODropdown(fieldName);
|
||||
const match = document.querySelector(`.o_field_many2one[name=${fieldName}] input`);
|
||||
await editInput(match, text);
|
||||
return clickM2OItem(fieldName, "Create and Edit");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the active (highlighted) selection in a m2o dropdown.
|
||||
*
|
||||
* @param {string} fieldName
|
||||
* @param {[string]} selector if set, this will restrict the search for the m2o
|
||||
* input
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function clickM2OHighlightedItem(fieldName, selector) {
|
||||
const m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`;
|
||||
const $dropdown = $(m2oSelector).autocomplete('widget');
|
||||
// clicking on an li (no matter which one), will select the focussed one
|
||||
return testUtilsDom.click($dropdown[0].querySelector('li'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on a menuitem in the m2o dropdown. This helper will target an element
|
||||
* which contains some specific text. Note that it assumes that the dropdown
|
||||
* is currently open.
|
||||
*
|
||||
* Example:
|
||||
* testUtils.fields.many2one.clickM2OItem('partner_id', 'George');
|
||||
*
|
||||
* @param {string} fieldName
|
||||
* @param {string} searchText
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function clickM2OItem(fieldName, searchText) {
|
||||
const m2oSelector = `.o_field_many2one[name=${fieldName}] input`;
|
||||
const $dropdown = $(m2oSelector).autocomplete('widget');
|
||||
const $target = $dropdown.find(`li:contains(${searchText})`).first();
|
||||
if ($target.length !== 1 || !$target.is(':visible')) {
|
||||
throw new Error('Menu item should be visible');
|
||||
}
|
||||
$target.mouseenter(); // This is NOT a mouseenter event. See jquery.js:5516 for more headaches.
|
||||
return testUtilsDom.click($target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click to open the dropdown on a many2one
|
||||
*
|
||||
* @param {string} fieldName
|
||||
* @param {[string]} selector if set, this will restrict the search for the m2o
|
||||
* input
|
||||
* @returns {Promise<HTMLInputElement>} the main many2one input
|
||||
*/
|
||||
async function clickOpenM2ODropdown(fieldName, selector) {
|
||||
const m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`;
|
||||
const matches = document.querySelectorAll(m2oSelector);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`);
|
||||
}
|
||||
|
||||
await testUtilsDom.click(matches[0]);
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of an element and then, trigger all specified events.
|
||||
* Note that this helper also checks the unicity of the target.
|
||||
*
|
||||
* Example:
|
||||
* testUtils.fields.editAndTrigger($('selector'), 'test', ['input', 'change']);
|
||||
*
|
||||
* @param {jQuery|EventTarget} el should target an input, textarea or select
|
||||
* @param {string|number} value
|
||||
* @param {string[]} events
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function editAndTrigger(el, value, events) {
|
||||
if (el instanceof jQuery) {
|
||||
if (el.length !== 1) {
|
||||
throw new Error(`target ${el.selector} has length ${el.length} instead of 1`);
|
||||
}
|
||||
el.val(value);
|
||||
} else {
|
||||
el.value = value;
|
||||
}
|
||||
return testUtilsDom.triggerEvents(el, events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of an input.
|
||||
*
|
||||
* Note that this helper also checks the unicity of the target.
|
||||
*
|
||||
* Example:
|
||||
* testUtils.fields.editInput($('selector'), 'somevalue');
|
||||
*
|
||||
* @param {jQuery|EventTarget} el should target an input, textarea or select
|
||||
* @param {string|number} value
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function editInput(el, value) {
|
||||
return editAndTrigger(el, value, ['input']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a select.
|
||||
*
|
||||
* Note that this helper also checks the unicity of the target.
|
||||
*
|
||||
* Example:
|
||||
* testUtils.fields.editSelect($('selector'), 'somevalue');
|
||||
*
|
||||
* @param {jQuery|EventTarget} el should target an input, textarea or select
|
||||
* @param {string|number} value
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function editSelect(el, value) {
|
||||
return editAndTrigger(el, value, ['change']);
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper is useful to test many2one fields. Here is what it does:
|
||||
* - click to open the dropdown
|
||||
* - enter a search string in the input
|
||||
* - wait for the selection
|
||||
* - click on the requested menuitem, or the active one by default
|
||||
*
|
||||
* Example:
|
||||
* testUtils.fields.many2one.searchAndClickM2OItem('partner_id', {search: 'George'});
|
||||
*
|
||||
* @param {string} fieldName
|
||||
* @param {[Object]} [options = {}]
|
||||
* @param {[string]} [options.selector]
|
||||
* @param {[string]} [options.search]
|
||||
* @param {[string]} [options.item]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function searchAndClickM2OItem(fieldName, options = {}) {
|
||||
const input = await clickOpenM2ODropdown(fieldName, options.selector);
|
||||
if (options.search) {
|
||||
await editInput(input, options.search);
|
||||
}
|
||||
if (options.item) {
|
||||
return clickM2OItem(fieldName, options.item);
|
||||
} else {
|
||||
return clickM2OHighlightedItem(fieldName, options.selector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to trigger a key event on an element.
|
||||
*
|
||||
* @param {string} type type of key event ('press', 'up' or 'down')
|
||||
* @param {jQuery} $el
|
||||
* @param {number|string} keyCode used as number, but if string, it'll check if
|
||||
* the string corresponds to a key -otherwise it will keep only the first
|
||||
* char to get a letter key- and convert it into a keyCode.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function triggerKey(type, $el, keyCode) {
|
||||
type = 'key' + type;
|
||||
const params = {};
|
||||
if (typeof keyCode === 'string') {
|
||||
// Key (new API)
|
||||
if (keyCode in ARROW_KEYS_MAPPING) {
|
||||
params.key = ARROW_KEYS_MAPPING[keyCode];
|
||||
} else {
|
||||
params.key = keyCode[0].toUpperCase() + keyCode.slice(1).toLowerCase();
|
||||
}
|
||||
// KeyCode/which (jQuery)
|
||||
if (keyCode.length > 1) {
|
||||
keyCode = keyCode.toUpperCase();
|
||||
keyCode = $.ui.keyCode[keyCode];
|
||||
} else {
|
||||
keyCode = keyCode.charCodeAt(0);
|
||||
}
|
||||
}
|
||||
params.keyCode = keyCode;
|
||||
params.which = keyCode;
|
||||
return testUtilsDom.triggerEvent($el, type, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to trigger a keydown event on an element.
|
||||
*
|
||||
* @param {jQuery} $el
|
||||
* @param {number|string} keyCode @see triggerKey
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function triggerKeydown($el, keyCode) {
|
||||
return triggerKey('down', $el, keyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to trigger a keyup event on an element.
|
||||
*
|
||||
* @param {jQuery} $el
|
||||
* @param {number|string} keyCode @see triggerKey
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function triggerKeyup($el, keyCode) {
|
||||
return triggerKey('up', $el, keyCode);
|
||||
}
|
||||
|
||||
return {
|
||||
clickM2OCreateAndEdit,
|
||||
clickM2OHighlightedItem,
|
||||
clickM2OItem,
|
||||
clickOpenM2ODropdown,
|
||||
editAndTrigger,
|
||||
editInput,
|
||||
editSelect,
|
||||
searchAndClickM2OItem,
|
||||
triggerKey,
|
||||
triggerKeydown,
|
||||
triggerKeyup,
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
odoo.define('web.test_utils_file', function () {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* FILE Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help simulate events with
|
||||
* files, such as drag-and-drop.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Private functions
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a fake object 'dataTransfer', linked to some files, which is passed to
|
||||
* drag and drop events.
|
||||
*
|
||||
* @param {Object[]} files
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _createFakeDataTransfer(files) {
|
||||
return {
|
||||
dropEffect: 'all',
|
||||
effectAllowed: 'all',
|
||||
files,
|
||||
getData: function () {
|
||||
return files;
|
||||
},
|
||||
items: [],
|
||||
types: ['Files'],
|
||||
};
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Public functions
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a file object, which can be used for drag-and-drop.
|
||||
*
|
||||
* @param {Object} data
|
||||
* @param {string} data.name
|
||||
* @param {string} data.content
|
||||
* @param {string} data.contentType
|
||||
* @returns {Promise<Object>} resolved with file created
|
||||
*/
|
||||
function createFile(data) {
|
||||
// Note: this is only supported by Chrome, and does not work in Incognito mode
|
||||
return new Promise(function (resolve, reject) {
|
||||
var requestFileSystem = window.requestFileSystem || window.webkitRequestFileSystem;
|
||||
if (!requestFileSystem) {
|
||||
throw new Error('FileSystem API is not supported');
|
||||
}
|
||||
requestFileSystem(window.TEMPORARY, 1024 * 1024, function (fileSystem) {
|
||||
fileSystem.root.getFile(data.name, { create: true }, function (fileEntry) {
|
||||
fileEntry.createWriter(function (fileWriter) {
|
||||
fileWriter.onwriteend = function (e) {
|
||||
fileSystem.root.getFile(data.name, {}, function (fileEntry) {
|
||||
fileEntry.file(function (file) {
|
||||
resolve(file);
|
||||
});
|
||||
});
|
||||
};
|
||||
fileWriter.write(new Blob([ data.content ], { type: data.contentType }));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a file over a DOM element
|
||||
*
|
||||
* @param {$.Element} $el
|
||||
* @param {Object} file must have been created beforehand (@see createFile)
|
||||
*/
|
||||
function dragoverFile($el, file) {
|
||||
var ev = new Event('dragover', { bubbles: true });
|
||||
Object.defineProperty(ev, 'dataTransfer', {
|
||||
value: _createFakeDataTransfer(file),
|
||||
});
|
||||
$el[0].dispatchEvent(ev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a file on a DOM element.
|
||||
*
|
||||
* @param {$.Element} $el
|
||||
* @param {Object} file must have been created beforehand (@see createFile)
|
||||
*/
|
||||
function dropFile($el, file) {
|
||||
var ev = new Event('drop', { bubbles: true, });
|
||||
Object.defineProperty(ev, 'dataTransfer', {
|
||||
value: _createFakeDataTransfer([file]),
|
||||
});
|
||||
$el[0].dispatchEvent(ev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop some files on a DOM element.
|
||||
*
|
||||
* @param {$.Element} $el
|
||||
* @param {Object[]} files must have been created beforehand (@see createFile)
|
||||
*/
|
||||
function dropFiles($el, files) {
|
||||
var ev = new Event('drop', { bubbles: true, });
|
||||
Object.defineProperty(ev, 'dataTransfer', {
|
||||
value: _createFakeDataTransfer(files),
|
||||
});
|
||||
$el[0].dispatchEvent(ev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set files in a file input
|
||||
*
|
||||
* @param {DOM.Element} el
|
||||
* @param {Object[]} files must have been created beforehand
|
||||
* @see testUtils.file.createFile
|
||||
*/
|
||||
function inputFiles(el, files) {
|
||||
// could not use _createFakeDataTransfer as el.files assignation will only
|
||||
// work with a real FileList object.
|
||||
const dataTransfer = new window.DataTransfer();
|
||||
for (const file of files) {
|
||||
dataTransfer.items.add(file);
|
||||
}
|
||||
el.files = dataTransfer.files;
|
||||
/**
|
||||
* Changing files programatically is not supposed to trigger the event but
|
||||
* it does in Chrome versions before 73 (which is on runbot), so in that
|
||||
* case there is no need to make a manual dispatch, because it would lead to
|
||||
* the files being added twice.
|
||||
*/
|
||||
const versionRaw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
|
||||
const chromeVersion = versionRaw ? parseInt(versionRaw[2], 10) : false;
|
||||
if (!chromeVersion || chromeVersion >= 73) {
|
||||
el.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Exposed API
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
createFile: createFile,
|
||||
dragoverFile: dragoverFile,
|
||||
dropFile: dropFile,
|
||||
dropFiles,
|
||||
inputFiles,
|
||||
};
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
odoo.define('web.test_utils_form', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Form Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help test form views.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
var testUtilsDom = require('web.test_utils_dom');
|
||||
|
||||
/**
|
||||
* Clicks on the Edit button in a form view, to set it to edit mode. Note that
|
||||
* it checks that the button is visible, so calling this method in edit mode
|
||||
* will fail.
|
||||
*
|
||||
* @param {FormController} form
|
||||
*/
|
||||
function clickEdit(form) {
|
||||
return testUtilsDom.click(form.$buttons.find('.o_form_button_edit'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on the Save button in a form view. Note that this method checks that
|
||||
* the Save button is visible.
|
||||
*
|
||||
* @param {FormController} form
|
||||
*/
|
||||
function clickSave(form) {
|
||||
return testUtilsDom.click(form.$buttons.find('.o_form_button_save'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on the Create button in a form view. Note that this method checks that
|
||||
* the Create button is visible.
|
||||
*
|
||||
* @param {FormController} form
|
||||
*/
|
||||
function clickCreate(form) {
|
||||
return testUtilsDom.click(form.$buttons.find('.o_form_button_create'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on the Discard button in a form view. Note that this method checks that
|
||||
* the Discard button is visible.
|
||||
*
|
||||
* @param {FormController} form
|
||||
*/
|
||||
function clickDiscard(form) {
|
||||
return testUtilsDom.click(form.$buttons.find('.o_form_button_cancel'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads a form view.
|
||||
*
|
||||
* @param {FormController} form
|
||||
* @param {[Object]} params given to the controller reload method
|
||||
*/
|
||||
function reload(form, params) {
|
||||
return form.reload(params);
|
||||
}
|
||||
|
||||
return {
|
||||
clickEdit: clickEdit,
|
||||
clickSave: clickSave,
|
||||
clickCreate: clickCreate,
|
||||
clickDiscard: clickDiscard,
|
||||
reload: reload,
|
||||
};
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
odoo.define('web.test_utils_graph', function () {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Graph Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help test graph views.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Reloads a graph view.
|
||||
*
|
||||
* @param {GraphController} graph
|
||||
* @param {[Object]} params given to the controller reload method
|
||||
*/
|
||||
function reload(graph, params) {
|
||||
return graph.reload(params);
|
||||
}
|
||||
|
||||
return {
|
||||
reload: reload,
|
||||
};
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
odoo.define('web.test_utils_kanban', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Kanban Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help testing kanban views.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
var testUtilsDom = require('web.test_utils_dom');
|
||||
var testUtilsFields = require('web.test_utils_fields');
|
||||
|
||||
/**
|
||||
* Clicks on the Create button in a kanban view. Note that this method checks that
|
||||
* the Create button is visible.
|
||||
*
|
||||
* @param {KanbanController} kanban
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function clickCreate(kanban) {
|
||||
return testUtilsDom.click(kanban.$buttons.find('.o-kanban-button-new'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings menu for a column (in a grouped kanban view)
|
||||
*
|
||||
* @param {jQuery} $column
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function toggleGroupSettings($column) {
|
||||
var $dropdownToggler = $column.find('.o_kanban_config > a.dropdown-toggle');
|
||||
if (!$dropdownToggler.is(':visible')) {
|
||||
$dropdownToggler.css('display', 'block');
|
||||
}
|
||||
return testUtilsDom.click($dropdownToggler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a value in a quickcreate form view (this method assumes that the quick
|
||||
* create feature is active, and a sub form view is open)
|
||||
*
|
||||
* @param {kanbanController} kanban
|
||||
* @param {string|number} value
|
||||
* @param {[string]} fieldName
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function quickCreate(kanban, value, fieldName) {
|
||||
var additionalSelector = fieldName ? ('[name=' + fieldName + ']'): '';
|
||||
var enterEvent = $.Event(
|
||||
'keydown',
|
||||
{
|
||||
which: $.ui.keyCode.ENTER,
|
||||
keyCode: $.ui.keyCode.ENTER,
|
||||
}
|
||||
);
|
||||
return testUtilsFields.editAndTrigger(
|
||||
kanban.$('.o_kanban_quick_create input' + additionalSelector),
|
||||
value,
|
||||
['input', enterEvent]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads a kanban view.
|
||||
*
|
||||
* @param {KanbanController} kanban
|
||||
* @param {[Object]} params given to the controller reload method
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function reload(kanban, params) {
|
||||
return kanban.reload(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the setting dropdown of a kanban record. Note that the template of a
|
||||
* kanban record is not standardized, so this method will fail if the template
|
||||
* does not comply with the usual dom structure.
|
||||
*
|
||||
* @param {jQuery} $record
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function toggleRecordDropdown($record) {
|
||||
var $dropdownToggler = $record.find('.o_dropdown_kanban > a.dropdown-toggle');
|
||||
if (!$dropdownToggler.is(':visible')) {
|
||||
$dropdownToggler.css('display', 'block');
|
||||
}
|
||||
return testUtilsDom.click($dropdownToggler);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
clickCreate: clickCreate,
|
||||
quickCreate: quickCreate,
|
||||
reload: reload,
|
||||
toggleGroupSettings: toggleGroupSettings,
|
||||
toggleRecordDropdown: toggleRecordDropdown,
|
||||
};
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,742 @@
|
|||
odoo.define('web.test_utils_mock', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Mock Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help mocking data.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
const AbstractStorageService = require('web.AbstractStorageService');
|
||||
const AjaxService = require('web.AjaxService');
|
||||
const basic_fields = require('web.basic_fields');
|
||||
const Bus = require('web.Bus');
|
||||
const config = require('web.config');
|
||||
const core = require('web.core');
|
||||
const dom = require('web.dom');
|
||||
const FormController = require('web.FormController');
|
||||
const makeTestEnvironment = require('web.test_env');
|
||||
const MockServer = require('web.MockServer');
|
||||
const RamStorage = require('web.RamStorage');
|
||||
const session = require('web.session');
|
||||
const { patchWithCleanup, patchDate } = require("@web/../tests/helpers/utils");
|
||||
const { browser } = require("@web/core/browser/browser");
|
||||
const { assets } = require("@web/core/assets");
|
||||
const { processArch } = require("@web/legacy/legacy_load_views");
|
||||
|
||||
const { Component } = require("@odoo/owl");
|
||||
const DebouncedField = basic_fields.DebouncedField;
|
||||
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Private functions
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a mocked environment to be used by OWL components in tests, with
|
||||
* requested services (+ ajax, local_storage and session_storage) deployed.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {Bus} [params.bus]
|
||||
* @param {boolean} [params.debug]
|
||||
* @param {Object} [params.env]
|
||||
* @param {Bus} [params.env.bus]
|
||||
* @param {Object} [params.env.dataManager]
|
||||
* @param {Object} [params.env.services]
|
||||
* @param {Object[]} [params.favoriteFilters]
|
||||
* @param {Object} [params.services]
|
||||
* @param {Object} [params.session]
|
||||
* @param {MockServer} [mockServer]
|
||||
* @returns {Promise<Object>} env
|
||||
*/
|
||||
async function _getMockedOwlEnv(params, mockServer) {
|
||||
params.env = params.env || {};
|
||||
|
||||
const database = {parameters: params.translateParameters || {}};
|
||||
|
||||
// build the env
|
||||
const favoriteFilters = params.favoriteFilters;
|
||||
const debug = params.debug;
|
||||
const services = {};
|
||||
const env = Object.assign({}, params.env, {
|
||||
_t: params.env && params.env._t || Object.assign((s => s), { database }),
|
||||
browser: Object.assign({
|
||||
fetch: (resource, init) => mockServer.performFetch(resource, init),
|
||||
}, params.env.browser),
|
||||
bus: params.bus || params.env.bus || new Bus(),
|
||||
dataManager: Object.assign({
|
||||
load_action: (actionID, context) => {
|
||||
return mockServer.performRpc('/web/action/load', {
|
||||
action_id: actionID,
|
||||
additional_context: context,
|
||||
});
|
||||
},
|
||||
load_views: (params, options) => {
|
||||
return mockServer.performRpc('/web/dataset/call_kw/' + params.model, {
|
||||
args: [],
|
||||
kwargs: {
|
||||
context: params.context,
|
||||
options: options,
|
||||
views: params.views_descr,
|
||||
},
|
||||
method: 'get_views',
|
||||
model: params.model,
|
||||
}).then(function (views) {
|
||||
views = _.mapObject(views, viewParams => {
|
||||
return getView(mockServer, viewParams);
|
||||
});
|
||||
if (favoriteFilters && 'search' in views) {
|
||||
views.search.favoriteFilters = favoriteFilters;
|
||||
}
|
||||
return views;
|
||||
});
|
||||
},
|
||||
load_filters: params => {
|
||||
if (debug) {
|
||||
console.log('[mock] load_filters', params);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
}, params.env.dataManager),
|
||||
services: Object.assign(services, params.env.services),
|
||||
session: params.env.session || params.session || {},
|
||||
});
|
||||
|
||||
// deploy services into the env
|
||||
// determine services to instantiate (classes), and already register function services
|
||||
const servicesToDeploy = {};
|
||||
for (const name in params.services || {}) {
|
||||
const Service = params.services[name];
|
||||
if (Service.constructor.name === 'Class') {
|
||||
servicesToDeploy[name] = Service;
|
||||
} else {
|
||||
services[name] = Service;
|
||||
}
|
||||
}
|
||||
// always deploy ajax, local storage and session storage
|
||||
if (!servicesToDeploy.ajax) {
|
||||
const MockedAjaxService = AjaxService.extend({
|
||||
rpc: mockServer.performRpc.bind(mockServer),
|
||||
});
|
||||
services.ajax = new MockedAjaxService(env);
|
||||
}
|
||||
const RamStorageService = AbstractStorageService.extend({
|
||||
storage: new RamStorage(),
|
||||
});
|
||||
if (!servicesToDeploy.local_storage) {
|
||||
services.local_storage = new RamStorageService(env);
|
||||
}
|
||||
if (!servicesToDeploy.session_storage) {
|
||||
services.session_storage = new RamStorageService(env);
|
||||
}
|
||||
// deploy other requested services
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const serviceName = Object.keys(servicesToDeploy).find(serviceName => {
|
||||
const Service = servicesToDeploy[serviceName];
|
||||
return Service.prototype.dependencies.every(depName => {
|
||||
return env.services[depName];
|
||||
});
|
||||
});
|
||||
if (serviceName) {
|
||||
const Service = servicesToDeploy[serviceName];
|
||||
services[serviceName] = new Service(env);
|
||||
delete servicesToDeploy[serviceName];
|
||||
services[serviceName].start();
|
||||
} else {
|
||||
const serviceNames = _.keys(servicesToDeploy);
|
||||
if (serviceNames.length) {
|
||||
console.warn("Non loaded services:", serviceNames);
|
||||
}
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
// wait for asynchronous services to properly start
|
||||
await new Promise(setTimeout);
|
||||
|
||||
return env;
|
||||
}
|
||||
/**
|
||||
* This function is used to mock global objects (session, config...) in tests.
|
||||
* It is necessary for legacy widgets. It returns a cleanUp function to call at
|
||||
* the end of the test.
|
||||
*
|
||||
* The function could be removed as soon as we do not support legacy widgets
|
||||
* anymore.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {Object} [params.config] if given, it is used to extend the global
|
||||
* config,
|
||||
* @param {Object} [params.session] if given, it is used to extend the current,
|
||||
* real session.
|
||||
* @param {Object} [params.translateParameters] if given, it will be used to
|
||||
* extend the core._t.database.parameters object.
|
||||
* @returns {function} a cleanUp function to restore everything, to call at the
|
||||
* end of the test
|
||||
*/
|
||||
function _mockGlobalObjects(params) {
|
||||
// store initial session state (for restoration)
|
||||
const initialSession = Object.assign({}, session);
|
||||
const sessionPatch = Object.assign({
|
||||
getTZOffset() { return 0; },
|
||||
async user_has_group() { return false; },
|
||||
}, params.session);
|
||||
// patch session
|
||||
Object.assign(session, sessionPatch);
|
||||
|
||||
// patch config
|
||||
let initialConfig;
|
||||
if ('config' in params) {
|
||||
initialConfig = Object.assign({}, config);
|
||||
initialConfig.device = Object.assign({}, config.device);
|
||||
if ('device' in params.config) {
|
||||
Object.assign(config.device, params.config.device);
|
||||
}
|
||||
if ('debug' in params.config) {
|
||||
odoo.debug = params.config.debug;
|
||||
}
|
||||
}
|
||||
|
||||
// patch translate params
|
||||
let initialParameters;
|
||||
if ('translateParameters' in params) {
|
||||
initialParameters = Object.assign({}, core._t.database.parameters);
|
||||
Object.assign(core._t.database.parameters, params.translateParameters);
|
||||
}
|
||||
|
||||
// build the cleanUp function to restore everything at the end of the test
|
||||
function cleanUp() {
|
||||
let key;
|
||||
for (key in sessionPatch) {
|
||||
delete session[key];
|
||||
}
|
||||
Object.assign(session, initialSession);
|
||||
if ('config' in params) {
|
||||
for (key in config) {
|
||||
delete config[key];
|
||||
}
|
||||
_.extend(config, initialConfig);
|
||||
}
|
||||
if ('translateParameters' in params) {
|
||||
for (key in core._t.database.parameters) {
|
||||
delete core._t.database.parameters[key];
|
||||
}
|
||||
_.extend(core._t.database.parameters, initialParameters);
|
||||
}
|
||||
}
|
||||
|
||||
return cleanUp;
|
||||
}
|
||||
/**
|
||||
* logs all event going through the target widget.
|
||||
*
|
||||
* @param {Widget} widget
|
||||
*/
|
||||
function _observe(widget) {
|
||||
var _trigger_up = widget._trigger_up.bind(widget);
|
||||
widget._trigger_up = function (event) {
|
||||
console.log('%c[event] ' + event.name, 'color: blue; font-weight: bold;', event);
|
||||
_trigger_up(event);
|
||||
};
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Public functions
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* performs a get_view, and mocks the postprocessing done by the
|
||||
* data_manager to return an equivalent structure.
|
||||
*
|
||||
* @param {MockServer} server
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @returns {Object} an object with 3 keys: arch, fields and viewFields
|
||||
*/
|
||||
function getView(server, params) {
|
||||
var view = server.getView(params);
|
||||
const fields = server.fieldsGet(params.model);
|
||||
// mock the structure produced by the DataManager
|
||||
const models = { [params.model]: fields };
|
||||
for (const modelName of view.models) {
|
||||
models[modelName] = models[modelName] || server.fieldsGet(modelName);
|
||||
}
|
||||
const { arch, viewFields } = processArch(view.arch, view.type, params.model, models);
|
||||
return {
|
||||
arch,
|
||||
fields,
|
||||
model: view.model,
|
||||
toolbar: view.toolbar,
|
||||
type: view.type,
|
||||
viewFields,
|
||||
view_id: view.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* intercepts an event bubbling up the widget hierarchy. The event intercepted
|
||||
* must be a "custom event", i.e. an event generated by the method 'trigger_up'.
|
||||
*
|
||||
* Note that this method really intercepts the event if @propagate is not set.
|
||||
* It will not be propagated further, and even the handlers on the target will
|
||||
* not fire.
|
||||
*
|
||||
* @param {Widget} widget the target widget (any Odoo widget)
|
||||
* @param {string} eventName description of the event
|
||||
* @param {function} fn callback executed when the even is intercepted
|
||||
* @param {boolean} [propagate=false]
|
||||
*/
|
||||
function intercept(widget, eventName, fn, propagate) {
|
||||
var _trigger_up = widget._trigger_up.bind(widget);
|
||||
widget._trigger_up = function (event) {
|
||||
if (event.name === eventName) {
|
||||
fn(event);
|
||||
if (!propagate) { return; }
|
||||
}
|
||||
_trigger_up(event);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a mock environment to test Owl Components. This function generates a test
|
||||
* env and sets it on the given Component. It also has several side effects,
|
||||
* like patching the global session or config objects. It returns a cleanup
|
||||
* function to call at the end of the test.
|
||||
*
|
||||
* @param {Component} Component
|
||||
* @param {Object} [params]
|
||||
* @param {Object} [params.actions]
|
||||
* @param {Object} [params.archs]
|
||||
* @param {string} [params.currentDate]
|
||||
* @param {Object} [params.data]
|
||||
* @param {boolean} [params.debug]
|
||||
* @param {function} [params.mockFetch]
|
||||
* @param {function} [params.mockRPC]
|
||||
* @param {number} [params.fieldDebounce=0] the value of the DEBOUNCE attribute
|
||||
* of fields
|
||||
* @param {boolean} [params.debounce=true] if false, patch _.debounce to remove
|
||||
* its behavior
|
||||
* @param {boolean} [params.throttle=false] by default, _.throttle is patched to
|
||||
* remove its behavior, except if this params is set to true
|
||||
* @param {boolean} [params.mockSRC=false] if true, redirect src GET requests to
|
||||
* the mockServer
|
||||
* @param {MockServer} [mockServer]
|
||||
* @returns {Promise<function>} the cleanup function
|
||||
*/
|
||||
async function addMockEnvironmentOwl(Component, params, mockServer) {
|
||||
params = params || {};
|
||||
|
||||
// instantiate a mockServer if not provided
|
||||
if (!mockServer) {
|
||||
let Server = MockServer;
|
||||
if (params.mockFetch) {
|
||||
Server = Server.extend({ _performFetch: params.mockFetch });
|
||||
}
|
||||
if (params.mockRPC) {
|
||||
Server = Server.extend({ _performRpc: params.mockRPC });
|
||||
}
|
||||
mockServer = new Server(params.data, {
|
||||
actions: params.actions,
|
||||
archs: params.archs,
|
||||
currentDate: params.currentDate,
|
||||
debug: params.debug,
|
||||
});
|
||||
}
|
||||
|
||||
patchWithCleanup(browser, {
|
||||
fetch: async (url, args) => {
|
||||
const result = await mockServer.performFetch(url, args || {});
|
||||
return {
|
||||
json: () => result,
|
||||
text: () => result,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (params.mockFetch) {
|
||||
const { loadJS, loadCSS } = assets;
|
||||
patchWithCleanup(assets, {
|
||||
loadJS: async function (ressource) {
|
||||
let res = await params.mockFetch(ressource, {});
|
||||
if (res === undefined) {
|
||||
res = await loadJS(ressource);
|
||||
} else {
|
||||
console.log("%c[assets] fetch (mock) JS ressource " + ressource, "color: #66e; font-weight: bold;");
|
||||
}
|
||||
return res;
|
||||
},
|
||||
loadCSS: async function (ressource) {
|
||||
let res = await params.mockFetch(ressource, {});
|
||||
if (res === undefined) {
|
||||
res = await loadCSS(ressource);
|
||||
} else {
|
||||
console.log("%c[assets] fetch (mock) CSS ressource " + ressource, "color: #66e; font-weight: bold;");
|
||||
}
|
||||
return res;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// remove the multi-click delay for the quick edit in form view
|
||||
const initialQuickEditDelay = FormController.prototype.multiClickTime;
|
||||
FormController.prototype.multiClickTime = params.formMultiClickTime || 0;
|
||||
|
||||
// make sure the debounce value for input fields is set to 0
|
||||
const initialDebounceValue = DebouncedField.prototype.DEBOUNCE;
|
||||
DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0;
|
||||
const initialDOMDebounceValue = dom.DEBOUNCE;
|
||||
dom.DEBOUNCE = 0;
|
||||
|
||||
// patch underscore debounce/throttle functions
|
||||
const initialDebounce = _.debounce;
|
||||
if (params.debounce === false) {
|
||||
_.debounce = function (func) {
|
||||
return func;
|
||||
};
|
||||
}
|
||||
// fixme: throttle is inactive by default, should we make it explicit ?
|
||||
const initialThrottle = _.throttle;
|
||||
if (!('throttle' in params) || !params.throttle) {
|
||||
_.throttle = function (func) {
|
||||
return func;
|
||||
};
|
||||
}
|
||||
|
||||
// mock global objects for legacy widgets (session, config...)
|
||||
const restoreMockedGlobalObjects = _mockGlobalObjects(params);
|
||||
|
||||
// set the test env on owl Component
|
||||
const env = await _getMockedOwlEnv(params, mockServer);
|
||||
const originalEnv = Component.env;
|
||||
const __env = makeTestEnvironment(env, mockServer.performRpc.bind(mockServer));
|
||||
owl.Component.env = __env;
|
||||
|
||||
// while we have a mix between Owl and legacy stuff, some of them triggering
|
||||
// events on the env.bus (a new Bus instance especially created for the current
|
||||
// test), the others using core.bus, we have to ensure that events triggered
|
||||
// on env.bus are also triggered on core.bus (note that outside the testing
|
||||
// environment, both are the exact same instance of Bus)
|
||||
const envBusTrigger = env.bus.trigger;
|
||||
env.bus.trigger = function () {
|
||||
core.bus.trigger(...arguments);
|
||||
envBusTrigger.call(env.bus, ...arguments);
|
||||
};
|
||||
|
||||
// build the clean up function to call at the end of the test
|
||||
function cleanUp() {
|
||||
env.bus.destroy();
|
||||
Object.keys(env.services).forEach(function (s) {
|
||||
var service = env.services[s] || {};
|
||||
if (service.destroy && !service.isDestroyed()) {
|
||||
service.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
FormController.prototype.multiClickTime = initialQuickEditDelay;
|
||||
|
||||
DebouncedField.prototype.DEBOUNCE = initialDebounceValue;
|
||||
dom.DEBOUNCE = initialDOMDebounceValue;
|
||||
_.debounce = initialDebounce;
|
||||
_.throttle = initialThrottle;
|
||||
|
||||
// clear the caches (e.g. data_manager, ModelFieldSelector) at the end
|
||||
// of each test to avoid collisions
|
||||
core.bus.trigger('clear_cache');
|
||||
|
||||
$('body').off('DOMNodeInserted.removeSRC');
|
||||
$('.blockUI').remove(); // fixme: move to qunit_config in OdooAfterTestHook?
|
||||
|
||||
restoreMockedGlobalObjects();
|
||||
|
||||
Component.env = originalEnv;
|
||||
}
|
||||
|
||||
return cleanUp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a mock environment to a widget. This helper function can simulate
|
||||
* various kind of side effects, such as mocking RPCs, changing the session,
|
||||
* or the translation settings.
|
||||
*
|
||||
* The simulated environment lasts for the lifecycle of the widget, meaning it
|
||||
* disappears when the widget is destroyed. It is particularly relevant for the
|
||||
* session mocks, because the previous session is restored during the destroy
|
||||
* call. So, it means that you have to be careful and make sure that it is
|
||||
* properly destroyed before another test is run, otherwise you risk having
|
||||
* interferences between tests.
|
||||
*
|
||||
* @param {Widget} widget
|
||||
* @param {Object} params
|
||||
* @param {Object} [params.archs] a map of string [model,view_id,view_type] to
|
||||
* a arch object. It is used to mock answers to 'load_views' custom events.
|
||||
* This is useful when the widget instantiate a formview dialog that needs
|
||||
* to load a particular arch.
|
||||
* @param {string} [params.currentDate] a string representation of the current
|
||||
* date. It is given to the mock server.
|
||||
* @param {Object} params.data the data given to the created mock server. It is
|
||||
* used to generate mock answers for every kind of routes supported by odoo
|
||||
* @param {number} [params.debug] if set to true, logs RPCs and uncaught Odoo
|
||||
* events.
|
||||
* @param {Object} [params.bus] the instance of Bus that will be used (in the env)
|
||||
* @param {function} [params.mockFetch] a function that will be used to override
|
||||
* the _performFetch method from the mock server. It is really useful to add
|
||||
* some custom fetch mocks, or to check some assertions.
|
||||
* @param {function} [params.mockRPC] a function that will be used to override
|
||||
* the _performRpc method from the mock server. It is really useful to add
|
||||
* some custom rpc mocks, or to check some assertions.
|
||||
* @param {Object} [params.session] if it is given, it will be used as answer
|
||||
* for all calls to this.getSession() by the widget, of its children. Also,
|
||||
* it will be used to extend the current, real session. This side effect is
|
||||
* undone when the widget is destroyed.
|
||||
* @param {Object} [params.translateParameters] if given, it will be used to
|
||||
* extend the core._t.database.parameters object. After the widget
|
||||
* destruction, the original parameters will be restored.
|
||||
* @param {Object} [params.intercepts] an object with event names as key, and
|
||||
* callback as value. Each key,value will be used to intercept the event.
|
||||
* Note that this is particularly useful if you want to intercept events going
|
||||
* up in the init process of the view, because there are no other way to do it
|
||||
* after this method returns. Some events ('call_service', "load_views",
|
||||
* "get_session", "load_filters") have a special treatment beforehand.
|
||||
* @param {Object} [params.services={}] list of services to load in
|
||||
* addition to the ajax service. For instance, if a test needs the local
|
||||
* storage service in order to work, it can provide a mock version of it.
|
||||
* @param {boolean} [debounce=true] set to false to completely remove the
|
||||
* debouncing, forcing the handler to be called directly (not on the next
|
||||
* execution stack, like it does with delay=0).
|
||||
* @param {boolean} [throttle=false] set to true to keep the throttling, which
|
||||
* is completely removed by default.
|
||||
*
|
||||
* @returns {Promise<MockServer>} the instance of the mock server, created by this
|
||||
* function. It is necessary for createView so that method can call some
|
||||
* other methods on it.
|
||||
*/
|
||||
async function addMockEnvironment(widget, params) {
|
||||
// log events triggered up if debug flag is true
|
||||
if (params.debug) {
|
||||
_observe(widget);
|
||||
var separator = window.location.href.indexOf('?') !== -1 ? "&" : "?";
|
||||
var url = window.location.href + separator + 'testId=' + QUnit.config.current.testId;
|
||||
console.log('%c[debug] debug mode activated', 'color: blue; font-weight: bold;', url);
|
||||
}
|
||||
|
||||
// instantiate mock server
|
||||
var Server = MockServer;
|
||||
if (params.mockFetch) {
|
||||
Server = MockServer.extend({ _performFetch: params.mockFetch });
|
||||
}
|
||||
if (params.mockRPC) {
|
||||
Server = Server.extend({ _performRpc: params.mockRPC });
|
||||
}
|
||||
var mockServer = new Server(params.data, {
|
||||
actions: params.actions,
|
||||
archs: params.archs,
|
||||
currentDate: params.currentDate,
|
||||
debug: params.debug,
|
||||
widget: widget,
|
||||
});
|
||||
|
||||
// build and set the Owl env on Component
|
||||
if (!('mockSRC' in params)) { // redirect src rpcs to the mock server
|
||||
params.mockSRC = true;
|
||||
}
|
||||
const cleanUp = await addMockEnvironmentOwl(Component, params, mockServer);
|
||||
const env = Component.env;
|
||||
|
||||
// ensure to clean up everything when the widget will be destroyed
|
||||
const destroy = widget.destroy;
|
||||
widget.destroy = function () {
|
||||
cleanUp();
|
||||
destroy.call(this, ...arguments);
|
||||
};
|
||||
|
||||
// intercept service/data manager calls and redirect them to the env
|
||||
intercept(widget, 'call_service', function (ev) {
|
||||
if (env.services[ev.data.service]) {
|
||||
var service = env.services[ev.data.service];
|
||||
const result = service[ev.data.method].apply(service, ev.data.args || []);
|
||||
ev.data.callback(result);
|
||||
}
|
||||
});
|
||||
intercept(widget, 'load_action', async ev => {
|
||||
const action = await env.dataManager.load_action(ev.data.actionID, ev.data.context);
|
||||
ev.data.on_success(action);
|
||||
});
|
||||
intercept(widget, "load_views", async ev => {
|
||||
const params = {
|
||||
model: ev.data.modelName,
|
||||
context: ev.data.context,
|
||||
views_descr: ev.data.views,
|
||||
};
|
||||
const views = await env.dataManager.load_views(params, ev.data.options);
|
||||
if ('search' in views && params.favoriteFilters) {
|
||||
views.search.favoriteFilters = params.favoriteFilters;
|
||||
}
|
||||
ev.data.on_success(views);
|
||||
});
|
||||
intercept(widget, "get_session", ev => {
|
||||
ev.data.callback(session);
|
||||
});
|
||||
intercept(widget, "load_filters", async ev => {
|
||||
const filters = await env.dataManager.load_filters(ev.data);
|
||||
ev.data.on_success(filters);
|
||||
});
|
||||
|
||||
// make sure all other Odoo events bubbling up are intercepted
|
||||
Object.keys(params.intercepts || {}).forEach(function (name) {
|
||||
intercept(widget, name, params.intercepts[name]);
|
||||
});
|
||||
|
||||
return mockServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch window.Date so that the time starts its flow from the provided Date.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* ```
|
||||
* testUtils.mock.patchDate(2018, 0, 10, 17, 59, 30)
|
||||
* new window.Date(); // "Wed Jan 10 2018 17:59:30 GMT+0100 (Central European Standard Time)"
|
||||
* ... // 5 hours delay
|
||||
* new window.Date(); // "Wed Jan 10 2018 22:59:30 GMT+0100 (Central European Standard Time)"
|
||||
* ```
|
||||
*
|
||||
* The returned function is there to preserve the former API. Before it was
|
||||
* necessary to call that function to unpatch the date. Now the unpatch is
|
||||
* done automatically via a call to registerCleanup.
|
||||
*
|
||||
* @param {integer} year
|
||||
* @param {integer} month index of the month, starting from zero.
|
||||
* @param {integer} day the day of the month.
|
||||
* @param {integer} hours the digits for hours (24h)
|
||||
* @param {integer} minutes
|
||||
* @param {integer} seconds
|
||||
* @returns {Function} callback function is now useless
|
||||
*/
|
||||
function legacyPatchDate(year, month, day, hours, minutes, seconds) {
|
||||
patchDate(year, month, day, hours, minutes, seconds);
|
||||
return function () {}; // all calls to that function are now useless
|
||||
}
|
||||
|
||||
var patches = {};
|
||||
/**
|
||||
* Patches a given Class or Object with the given properties.
|
||||
*
|
||||
* @param {Class|Object} target
|
||||
* @param {Object} props
|
||||
*/
|
||||
function patch(target, props) {
|
||||
var patchID = _.uniqueId('patch_');
|
||||
target.__patchID = patchID;
|
||||
patches[patchID] = {
|
||||
target: target,
|
||||
otherPatchedProps: [],
|
||||
ownPatchedProps: [],
|
||||
};
|
||||
if (target.prototype) {
|
||||
_.each(props, function (value, key) {
|
||||
if (target.prototype.hasOwnProperty(key)) {
|
||||
patches[patchID].ownPatchedProps.push({
|
||||
key: key,
|
||||
initialValue: target.prototype[key],
|
||||
});
|
||||
} else {
|
||||
patches[patchID].otherPatchedProps.push(key);
|
||||
}
|
||||
});
|
||||
target.include(props);
|
||||
} else {
|
||||
_.each(props, function (value, key) {
|
||||
if (key in target) {
|
||||
var oldValue = target[key];
|
||||
patches[patchID].ownPatchedProps.push({
|
||||
key: key,
|
||||
initialValue: oldValue,
|
||||
});
|
||||
if (typeof value === 'function') {
|
||||
target[key] = function () {
|
||||
var oldSuper = this._super;
|
||||
this._super = oldValue;
|
||||
var result = value.apply(this, arguments);
|
||||
if (oldSuper === undefined) {
|
||||
delete this._super;
|
||||
} else {
|
||||
this._super = oldSuper;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
} else {
|
||||
patches[patchID].otherPatchedProps.push(key);
|
||||
target[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpatches a given Class or Object.
|
||||
*
|
||||
* @param {Class|Object} target
|
||||
*/
|
||||
function unpatch(target) {
|
||||
var patchID = target.__patchID;
|
||||
var patch = patches[patchID];
|
||||
if (target.prototype) {
|
||||
_.each(patch.ownPatchedProps, function (p) {
|
||||
target.prototype[p.key] = p.initialValue;
|
||||
});
|
||||
_.each(patch.otherPatchedProps, function (key) {
|
||||
delete target.prototype[key];
|
||||
});
|
||||
} else {
|
||||
_.each(patch.ownPatchedProps, function (p) {
|
||||
target[p.key] = p.initialValue;
|
||||
});
|
||||
_.each(patch.otherPatchedProps, function (key) {
|
||||
delete target[key];
|
||||
});
|
||||
}
|
||||
delete patches[patchID];
|
||||
delete target.__patchID;
|
||||
}
|
||||
|
||||
window.originalSetTimeout = window.setTimeout;
|
||||
function patchSetTimeout() {
|
||||
var original = window.setTimeout;
|
||||
var self = this;
|
||||
window.setTimeout = function (handler, delay) {
|
||||
console.log("calling setTimeout on " + (handler.name || "some function") + "with delay of " + delay);
|
||||
console.trace();
|
||||
var handlerArguments = Array.prototype.slice.call(arguments, 1);
|
||||
return original(function () {
|
||||
handler.bind(self, handlerArguments)();
|
||||
console.log('after doing the action of the setTimeout');
|
||||
}, delay);
|
||||
};
|
||||
|
||||
return function () {
|
||||
window.setTimeout = original;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
addMockEnvironment: addMockEnvironment,
|
||||
getView: getView,
|
||||
addMockEnvironmentOwl: addMockEnvironmentOwl,
|
||||
intercept: intercept,
|
||||
patchDate: legacyPatchDate,
|
||||
patch: patch,
|
||||
unpatch: unpatch,
|
||||
patchSetTimeout: patchSetTimeout,
|
||||
};
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
odoo.define('web.test_utils_modal', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Modal Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help test pivot views.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
const { _t } = require('web.core');
|
||||
const testUtilsDom = require('web.test_utils_dom');
|
||||
|
||||
/**
|
||||
* Click on a button in the footer of a modal (which contains a given string).
|
||||
*
|
||||
* @param {string} text (in english: this method will perform the translation)
|
||||
*/
|
||||
function clickButton(text) {
|
||||
return testUtilsDom.click($(`.modal-footer button:contains(${_t(text)})`));
|
||||
}
|
||||
|
||||
return { clickButton };
|
||||
});
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
odoo.define('web.test_utils_pivot', function (require) {
|
||||
"use strict";
|
||||
|
||||
var testUtilsDom = require('web.test_utils_dom');
|
||||
|
||||
/**
|
||||
* Pivot Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help test pivot views.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Select a measure by clicking on the corresponding dropdown item (in the
|
||||
* control panel 'Measure' submenu).
|
||||
*
|
||||
* Note that this method assumes that the dropdown menu is open.
|
||||
* @see toggleMeasuresDropdown
|
||||
*
|
||||
* @param {PivotController} pivot
|
||||
* @param {string} measure
|
||||
*/
|
||||
function clickMeasure(pivot, measure) {
|
||||
return testUtilsDom.click(pivot.$buttons.find(`.dropdown-item[data-field=${measure}]`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the 'Measure' dropdown menu (in the control panel)
|
||||
*
|
||||
* @see clickMeasure
|
||||
*
|
||||
* @param {PivotController} pivot
|
||||
*/
|
||||
function toggleMeasuresDropdown(pivot) {
|
||||
return testUtilsDom.click(pivot.$buttons.filter('.btn-group:first').find('> button'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads a graph view.
|
||||
*
|
||||
* @param {PivotController} pivot
|
||||
* @param {[Object]} params given to the controller reload method
|
||||
*/
|
||||
function reload(pivot, params) {
|
||||
return pivot.reload(params);
|
||||
}
|
||||
|
||||
return {
|
||||
clickMeasure: clickMeasure,
|
||||
reload: reload,
|
||||
toggleMeasuresDropdown: toggleMeasuresDropdown,
|
||||
};
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
odoo.define('web.testUtilsTests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
QUnit.module('web', {}, function () {
|
||||
QUnit.module('testUtils', {}, function () {
|
||||
|
||||
QUnit.module('patch date');
|
||||
|
||||
QUnit.test('new date', function (assert) {
|
||||
assert.expect(5);
|
||||
const unpatchDate = testUtils.mock.patchDate(2018, 9, 23, 14, 50, 0);
|
||||
|
||||
const date = new Date();
|
||||
|
||||
assert.strictEqual(date.getFullYear(), 2018);
|
||||
assert.strictEqual(date.getMonth(), 9);
|
||||
assert.strictEqual(date.getDate(), 23);
|
||||
assert.strictEqual(date.getHours(), 14);
|
||||
assert.strictEqual(date.getMinutes(), 50);
|
||||
unpatchDate();
|
||||
});
|
||||
|
||||
QUnit.test('new moment', function (assert) {
|
||||
assert.expect(1);
|
||||
const unpatchDate = testUtils.mock.patchDate(2018, 9, 23, 14, 50, 0);
|
||||
|
||||
const m = moment();
|
||||
assert.strictEqual(m.format('YYYY-MM-DD HH:mm'), '2018-10-23 14:50');
|
||||
unpatchDate();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue