From 49e78f46ee5829c89cc20676ec56ca08bdf8e573 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Tue, 20 Nov 2018 10:09:09 +0000 Subject: [PATCH] [IMP] web: tests: backport of new helpers This rev. is a partial backport of abf32b8b, so that the newly introduced helpers are already available in 12.0. closes odoo/odoo#28827 --- addons/web/static/tests/helpers/test_utils.js | 992 ++---------------- .../static/tests/helpers/test_utils_create.js | 288 +++++ .../static/tests/helpers/test_utils_dom.js | 235 +++++ .../static/tests/helpers/test_utils_fields.js | 172 +++ .../static/tests/helpers/test_utils_form.js | 74 ++ .../static/tests/helpers/test_utils_graph.js | 28 + .../static/tests/helpers/test_utils_kanban.js | 97 ++ .../static/tests/helpers/test_utils_mock.js | 545 ++++++++++ .../static/tests/helpers/test_utils_modal.js | 34 + .../static/tests/helpers/test_utils_pivot.js | 57 + addons/web/views/webclient_templates.xml | 9 + 11 files changed, 1621 insertions(+), 910 deletions(-) create mode 100644 addons/web/static/tests/helpers/test_utils_create.js create mode 100644 addons/web/static/tests/helpers/test_utils_dom.js create mode 100644 addons/web/static/tests/helpers/test_utils_fields.js create mode 100644 addons/web/static/tests/helpers/test_utils_form.js create mode 100644 addons/web/static/tests/helpers/test_utils_graph.js create mode 100644 addons/web/static/tests/helpers/test_utils_kanban.js create mode 100644 addons/web/static/tests/helpers/test_utils_mock.js create mode 100644 addons/web/static/tests/helpers/test_utils_modal.js create mode 100644 addons/web/static/tests/helpers/test_utils_pivot.js diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index f20f183d799..d90a50ad2ca 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -10,900 +10,18 @@ odoo.define('web.test_utils', function (require) { * instance of a view, appended in the dom, ready to be tested. */ -var ActionManager = require('web.ActionManager'); var ajax = require('web.ajax'); -var basic_fields = require('web.basic_fields'); -var config = require('web.config'); -var ControlPanel = require('web.ControlPanel'); var core = require('web.core'); -var DebugManager = require('web.DebugManager'); -var dom = require('web.dom'); var session = require('web.session'); -var MockServer = require('web.MockServer'); -var Widget = require('web.Widget'); - -var DebouncedField = basic_fields.DebouncedField; - -/** - * 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); - }; -} - -/** - * 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); - }; -} - -/** - * create and return an instance of ActionManager with all rpcs going through a - * mock method using the data, actions and archs objects as sources. - * - * @param {Object} [params] - * @param {Object} [params.actions] the actions given to the mock server - * @param {Object} [params.archs] this archs given to the mock server - * @param {Object} [params.data] the business data given to the mock server - * @param {boolean} [params.debug] - * @param {function} [params.mockRPC] - * @returns {ActionManager} - */ -var createActionManager = function (params) { - params = params || {}; - var $target = $('#qunit-fixture'); - if (params.debug) { - $target = $('body'); - $target.addClass('debug'); - } - - var widget = new Widget(); - // when 'document' addon is installed, the sidebar does a 'search_read' on - // model 'ir_attachment' each time a record is open, so we monkey-patch - // 'mockRPC' to mute those RPCs, so that the tests can be written uniformly, - // whether or not 'document' is installed - var mockRPC = params.mockRPC; - _.extend(params, { - mockRPC: function (route, args) { - if (args.model === 'ir.attachment') { - return $.when([]); - } - if (mockRPC) { - return mockRPC.apply(this, arguments); - } - return this._super.apply(this, arguments); - }, - }); - addMockEnvironment(widget, _.defaults(params, {debounce: false})); - widget.prependTo($target); - widget.$el.addClass('o_web_client'); - if (config.device.isMobile) { - widget.$el.addClass('o_touch_device'); - } - - var userContext = params.context && params.context.user_context || {}; - var actionManager = new ActionManager(widget, userContext); - - var originalDestroy = ActionManager.prototype.destroy; - actionManager.destroy = function () { - actionManager.destroy = originalDestroy; - widget.destroy(); - }; - actionManager.appendTo(widget.$el); - - return actionManager; -}; -/** - * Create and return an instance of DebugManager with all rpcs going through a - * mock method, assuming that the user has access rights, and is an admin. - * - * @param {Object} [params={}] - */ -var createDebugManager = function (params) { - params = params || {}; - var mockRPC = params.mockRPC; - _.extend(params, { - mockRPC: function (route, args) { - if (args.method === 'check_access_rights') { - return $.when(true); - } - if (args.method === 'xmlid_to_res_id') { - return $.when(true); - } - if (mockRPC) { - return mockRPC.apply(this, arguments); - } - return this._super.apply(this, arguments); - }, - session: { - user_has_group: function (group) { - if (group === 'base.group_no_one') { - return $.when(true); - } - return this._super.apply(this, arguments); - }, - }, - }); - var debugManager = new DebugManager(); - addMockEnvironment(debugManager, params); - return debugManager; -}; - -/** - * performs a fields_view_get, 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 fieldsViewGet(server, params) { - var fieldsView = server.fieldsViewGet(params); - // mock the structure produced by the DataManager - fieldsView.viewFields = fieldsView.fields; - fieldsView.fields = server.fieldsGet(params.model); - return fieldsView; -} - -/** - * create a view synchronously. This method uses the createAsyncView method. - * Most views are synchronous, so the deferred can be resolved immediately and - * this method will work. - * - * Be careful, if for some reason a view is async, this method will crash. - * @see createAsyncView - * - * @param {Object} params will be given to createAsyncView - * @returns {AbstractView} - */ -function createView(params) { - var view; - createAsyncView(params).then(function (result) { - view = result; - }); - if (!view) { - throw "The view that you are trying to create is async. Please use createAsyncView instead"; - } - return view; -} - -/** - * 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. - * - * Most views can be tested synchronously (@see createView), but some view have - * external dependencies (like lazy loaded libraries). In that case, it is - * necessary to use this method. - * - * @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 {Object} [params.debug=false] if true, the widget will be appended in - * the DOM. Also, RPCs and uncaught OdooEvent will be logged - * @param {string[]} [params.groupBy] the initial groupBy for the view - * @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 - * @returns {Deferred} resolves with the instance of the view - */ -function createAsyncView(params) { - var $target = $('#qunit-fixture'); - var widget = new Widget(); - if (params.debug) { - $target = $('body'); - $target.addClass('debug'); - } - - // add mock environment: mock server, session, fieldviewget, ... - var mockServer = addMockEnvironment(widget, params); - var viewInfo = fieldsViewGet(mockServer, params); - // create the view - var viewOptions = { - modelName: params.model || 'foo', - ids: 'res_id' in params ? [params.res_id] : undefined, - currentId: 'res_id' in params ? params.res_id : undefined, - domain: params.domain || [], - context: params.context || {}, - groupBy: params.groupBy || [], - }; - if (params.hasSelectors) { - viewOptions.hasSelectors = params.hasSelectors; - } - - _.extend(viewOptions, params.viewOptions); - - var view = new params.View(viewInfo, viewOptions); - - // reproduce the DOM environment of views - var $web_client = $('
').addClass('o_web_client').prependTo($target); - var controlPanel = new ControlPanel(widget); - controlPanel.appendTo($web_client); - var $content = $('
').addClass('o_content').appendTo($web_client); - - if (params.interceptsPropagate) { - _.each(params.interceptsPropagate, function (cb, name) { - intercept(widget, name, cb, true); - }); - } - - return view.getController(widget).then(function (view) { - // 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. - view.__destroy = view.destroy; - view.destroy = function () { - // remove the override to properly destroy the view and its children - // when it will be called the second time (by its parent) - delete view.destroy; - widget.destroy(); - }; - - // link the view to the control panel - view.set_cp_bus(controlPanel.get_bus()); - - // render the view in a fragment as they must be able to render correctly - // without being in the DOM - var fragment = document.createDocumentFragment(); - return view.appendTo(fragment).then(function () { - dom.append($content, fragment, { - callbacks: [{widget: view}], - in_DOM: true, - }); - view.$el.on('click', 'a', function (ev) { - ev.preventDefault(); - }); - - return view; - }); - }); -} - -/** - * Patch window.Date so that the time starts its flow from the provided Date. - * - * Usage: - * - * ``` - * var unpatchDate = testUtils.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)" - * ... - * unpatchDate(); - * new window.Date(); // actual current date time - * ``` - * - * @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} a callback to unpatch window.Date. - */ -function patchDate(year, month, day, hours, minutes, seconds) { - var RealDate = window.Date; - var actualDate = new RealDate(); - var fakeDate = new RealDate(year, month, day, hours, minutes, seconds); - var timeInterval = actualDate.getTime() - (fakeDate.getTime()); - - window.Date = function Date() { - if (arguments.length > 0) { - return RealDate.apply(this, arguments); - } else { - var date = new RealDate(); - var time = date.getTime(); - time -= timeInterval; - date.setTime(time); - return date; - } - }; - - _.mapObject(RealDate, function (val, key) { - window.Date[key] = val; - }); - window.Date.prototype = RealDate.prototype; - - return function () {window.Date = RealDate;}; -} - -/** - * 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 {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 {MockServer} the instance of the mock server, created by this - * function. It is necessary for createAsyncView so that method can call some - * other methods on it. - */ -function addMockEnvironment(widget, params) { - var Server = MockServer; - params.services = params.services || {}; - if (params.mockRPC) { - Server = MockServer.extend({_performRpc: params.mockRPC}); - } - 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); - } - - var mockServer = new Server(params.data, { - actions: params.actions, - archs: params.archs, - currentDate: params.currentDate, - debug: params.debug, - widget: widget, - }); - - // make sure images do not trigger a GET on the server - $('body').on('DOMNodeInserted.removeSRC', function (event) { - removeSrcAttribute(event.target, widget); - }); - - // make sure the debounce value for input fields is set to 0 - var initialDebounceValue = DebouncedField.prototype.DEBOUNCE; - DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0; - var initialSession, initialConfig, initialParameters, initialDebounce, initialThrottle; - initialSession = _.extend({}, session); - session.getTZOffset = function () { - return 0; // by default, but may be overriden in specific tests - }; - if ('session' in params) { - _.extend(session, params.session); - } - if ('config' in params) { - initialConfig = _.clone(config); - initialConfig.device = _.clone(config.device); - if ('device' in params.config) { - _.extend(config.device, params.config.device); - } - if ('debug' in params.config) { - config.debug = params.config.debug; - } - } - if ('translateParameters' in params) { - initialParameters = _.extend({}, core._t.database.parameters); - _.extend(core._t.database.parameters, params.translateParameters); - } - if (params.debounce === false) { - initialDebounce = _.debounce; - _.debounce = function (func) { - return func; - }; - } - if (!('throttle' in params) || !params.throttle) { - initialThrottle = _.throttle; - _.throttle = function (func) { - return func; - }; - } - - var widgetDestroy = widget.destroy; - widget.destroy = function () { - // clear the caches (e.g. data_manager, ModelFieldSelector) when the - // widget is destroyed, at the end of each test to avoid collisions - core.bus.trigger('clear_cache'); - - DebouncedField.prototype.DEBOUNCE = initialDebounceValue; - if (params.debounce === false) { - _.debounce = initialDebounce; - } - if (!('throttle' in params) || !params.throttle) { - _.throttle = initialThrottle; - } - - var key; - if ('session' in params) { - for (key in session) { - delete session[key]; - } - } - _.extend(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); - } - - $('body').off('DOMNodeInserted.removeSRC'); - $('.blockUI').remove(); - - widgetDestroy.call(this); - }; - - // Dispatch service calls - // Note: some services could call other services at init, - // Which is why we have to init services after that - var services = {}; - intercept(widget, 'call_service', function (ev) { - var args, result; - if (services[ev.data.service]) { - var service = services[ev.data.service]; - args = (ev.data.args || []); - result = service[ev.data.method].apply(service, args); - } else if (ev.data.service === 'ajax') { - // use ajax service that is mocked by the server - var route = ev.data.args[0]; - args = ev.data.args[1]; - result = mockServer.performRpc(route, args); - } - ev.data.callback(result); - }); - - intercept(widget, 'load_action', function (event) { - mockServer.performRpc('/web/action/load', { - kwargs: { - action_id: event.data.actionID, - additional_context: event.data.context, - }, - }).then(function (action) { - event.data.on_success(action); - }); - }); - - intercept(widget, "load_views", function (event) { - mockServer.performRpc('/web/dataset/call_kw/' + event.data.modelName, { - args: [], - kwargs: { - context: event.data.context, - options: event.data.options, - views: event.data.views, - }, - method: 'load_views', - model: event.data.modelName, - }).then(function (views) { - views = _.mapObject(views, function (viewParams) { - return fieldsViewGet(mockServer, viewParams); - }); - event.data.on_success(views); - }); - }); - - intercept(widget, "get_session", function (event) { - event.data.callback(session); - }); - - intercept(widget, "load_filters", function (event) { - if (params.debug) { - console.log('[mock] load_filters', event.data); - } - event.data.on_success([]); - }); - - // make sure all Odoo events bubbling up are intercepted - if ('intercepts' in params) { - _.each(params.intercepts, function (cb, name) { - intercept(widget, name, cb); - }); - } - - // Deploy services - var done = false; - var servicesToDeploy = _.clone(params.services); - if (!servicesToDeploy.ajax) { - services.ajax = null; // use mocked ajax from mocked server - } - while (!done) { - var serviceName = _.findKey(servicesToDeploy, function (Service) { - return !_.some(Service.prototype.dependencies, function (depName) { - return !_.has(services, depName); - }); - }); - if (serviceName) { - var Service = servicesToDeploy[serviceName]; - var service = services[serviceName] = new Service(widget); - delete servicesToDeploy[serviceName]; - - intercept(service, "get_session", function (event) { - event.data.callback(session); - }); - - service.start(); - } else { - var serviceNames = _.keys(servicesToDeploy); - if (serviceNames.length) { - console.warn("Non loaded services:", serviceNames); - } - done = true; - } - } - - return mockServer; -} - -/** - * 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} - */ -function createModel(params) { - var widget = new Widget(); - - var model = new params.Model(widget); - - 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 {Widget} - */ -function createParent(params) { - var widget = new Widget(); - addMockEnvironment(widget, params); - return widget; -} - -/** - * 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. - * - * @param {jqueryElement} $el - * @param {jqueryElement} $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) - */ -function dragAndDrop($el, $to, options) { - var position = (options && options.position) || 'center'; - var elementCenter = $el.offset(); - var toOffset = $to.offset(); - - if (_.isObject(position)) { - toOffset.top += position.top; - toOffset.left += position.left; - } else { - toOffset.top += $to.outerHeight()/2; - toOffset.left += $to.outerWidth()/2; - var 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 - var bound = $('iframe')[0].getBoundingClientRect(); - toOffset.left += bound.left; - toOffset.top += bound.top; - } - $el.trigger($.Event("mouseenter")); - if (!(options && options.continueMove)) { - elementCenter.left += $el.outerWidth()/2; - elementCenter.top += $el.outerHeight()/2; - - $el.trigger($.Event("mousedown", { - which: 1, - pageX: elementCenter.left, - pageY: elementCenter.top - })); - } - - $el.trigger($.Event("mousemove", { - which: 1, - pageX: toOffset.left, - pageY: toOffset.top - })); - - if (!(options && options.disableDrop)) { - $el.trigger($.Event("mouseup", { - which: 1, - pageX: toOffset.left, - pageY: toOffset.top - })); - } 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 () { - $el.trigger($.Event("mouseup")); - }); - } -} - -/** - * 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 {jqueryElement} $el - * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove' - */ -function triggerMouseEvent($el, type) { - var pos = $el.offset(); - var e = new $.Event(type); - e.pageX = e.layerX = e.screenX = pos.left; - e.pageY = e.layerY = e.screenY =pos.top; - e.which = 1; - $el.trigger(e); -} - -/** - * 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' - */ -function triggerPositionalMouseEvent(x, y, type){ - var ev = document.createEvent("MouseEvent"); - var 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 keypress event for a given character - * @param {string} the character - */ -function triggerKeypressEvent(char) { - var keycode; - if (char === "Enter") { - keycode = $.ui.keyCode.ENTER; - } else { - keycode = char.charCodeAt(0); - } - return $('body').trigger($.Event('keypress', {which: keycode, keyCode: keycode})); -} - -/** - * Removes the src attribute on images and iframes to prevent not found errors, - * and optionally triggers an rpc with the src url as route on a widget. - * This method is critical and must be fastest (=> no jQuery, no underscore) - * - * @param {DOM Node} el - * @param {[Widget]} widget the widget on which the rpc should be performed - */ -function removeSrcAttribute(el, widget) { - var nodes; - if (el.nodeName === 'IMG' || el.nodeName === 'IFRAME') { - nodes = [el]; - } else { - nodes = Array.prototype.slice.call(el.getElementsByTagName('img')) - .concat(Array.prototype.slice.call(el.getElementsByTagName('iframe'))); - } - var node; - while (node = nodes.pop()) { - var src = node.attributes.src && node.attributes.src.value; - if (src && src !== 'about:blank') { - var $el = $(node); - node.setAttribute('data-src', src); - if (node.nodeName === 'IMG') { - node.attributes.removeNamedItem('src'); - } else { - node.setAttribute('src', 'about:blank'); - } - if (widget) { - widget._rpc({route: src}); - } - } - } -} - -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; -} - -/** - * Opens the datepicker of a given element. - * - * @param {jQuery} $datepickerEl element to which a datepicker is attached - */ -function openDatepicker($datepickerEl) { - $datepickerEl.find('.o_datepicker_input').trigger('focus.datetimepicker'); -} +var testUtilsCreate = require('web.test_utils_create'); +var testUtilsDom = require('web.test_utils_dom'); +var testUtilsFields = require('web.test_utils_fields'); +var testUtilsForm = require('web.test_utils_form'); +var testUtilsGraph = require('web.test_utils_graph'); +var testUtilsKanban = require('web.test_utils_kanban'); +var testUtilsMock = require('web.test_utils_mock'); +var testUtilsModal = require('web.test_utils_modal'); +var testUtilsPivot = require('web.test_utils_pivot'); // 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, @@ -920,25 +38,79 @@ return $.when( QUnit.start(); }, 0); return { - addMockEnvironment: addMockEnvironment, - createActionManager: createActionManager, - createDebugManager: createDebugManager, - createAsyncView: createAsyncView, - createModel: createModel, - createParent: createParent, - createView: createView, - dragAndDrop: dragAndDrop, - fieldsViewGet: fieldsViewGet, - intercept: intercept, - observe: observe, - openDatepicker: openDatepicker, - patch: patch, - patchDate: patchDate, - removeSrcAttribute: removeSrcAttribute, - triggerKeypressEvent: triggerKeypressEvent, - triggerMouseEvent: triggerMouseEvent, - triggerPositionalMouseEvent: triggerPositionalMouseEvent, - unpatch: unpatch, + addMockEnvironment: testUtilsMock.addMockEnvironment, + createActionManager: testUtilsCreate.createActionManager, + createDebugManager: testUtilsCreate.createDebugManager, + createAsyncView: testUtilsCreate.createAsyncView, + createModel: testUtilsCreate.createModel, + createParent: testUtilsCreate.createParent, + createView: testUtilsCreate.createView, + dragAndDrop: testUtilsDom.dragAndDrop, + fieldsViewGet: testUtilsMock.fieldsViewGet, + intercept: testUtilsMock.intercept, + openDatepicker: testUtilsDom.openDatepicker, + patch: testUtilsMock.patch, + patchDate: testUtilsMock.patchDate, + triggerKeypressEvent: testUtilsDom.triggerKeypressEvent, + triggerMouseEvent: testUtilsDom.triggerMouseEvent, + triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent, + unpatch: testUtilsMock.unpatch, + + // backport of new tests helpers -> DO NOT FORWARD PORT + mock: { + addMockEnvironment: testUtilsMock.addMockEnvironment, + intercept: testUtilsMock.intercept, + patch: testUtilsMock.patch, + patchDate: testUtilsMock.patchDate, + unpatch: testUtilsMock.unpatch, + fieldsViewGet: testUtilsMock.fieldsViewGet, + }, + dom: { + triggerKeypressEvent: testUtilsDom.triggerKeypressEvent, + triggerMouseEvent: testUtilsDom.triggerMouseEvent, + triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent, + dragAndDrop: testUtilsDom.dragAndDrop, + openDatepicker: testUtilsDom.openDatepicker, + click: testUtilsDom.click, + clickFirst: testUtilsDom.clickFirst, + clickLast: testUtilsDom.clickLast, + }, + 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: { + clickOpenDropdown: testUtilsFields.clickOpenM2ODropdown, + clickHighlightedItem: testUtilsFields.clickM2OHighlightedItem, + clickItem: testUtilsFields.clickM2OItem, + searchAndClickItem: testUtilsFields.searchAndClickM2OItem, + }, + editInput: testUtilsFields.editInput, + editSelect: testUtilsFields.editSelect, + editAndTrigger: testUtilsFields.editAndTrigger, + }, }; }); diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js new file mode 100644 index 00000000000..d4d4e2a6b55 --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils_create.js @@ -0,0 +1,288 @@ +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. + */ + +var ActionManager = require('web.ActionManager'); +var config = require('web.config'); +var ControlPanel = require('web.ControlPanel'); +var dom = require('web.dom'); +var DebugManager = require('web.DebugManager'); +var testUtilsMock = require('web.test_utils_mock'); +var Widget = require('web.Widget'); + +/** + * create and return an instance of ActionManager with all rpcs going through a + * mock method using the data, actions and archs objects as sources. + * + * @param {Object} [params] + * @param {Object} [params.actions] the actions given to the mock server + * @param {Object} [params.archs] this archs given to the mock server + * @param {Object} [params.data] the business data given to the mock server + * @param {boolean} [params.debug] + * @param {function} [params.mockRPC] + * @returns {ActionManager} + */ +function createActionManager (params) { + params = params || {}; + var $target = $('#qunit-fixture'); + if (params.debug) { + $target = $('body'); + $target.addClass('debug'); + } + + var widget = new Widget(); + // when 'document' addon is installed, the sidebar does a 'search_read' on + // model 'ir_attachment' each time a record is open, so we monkey-patch + // 'mockRPC' to mute those RPCs, so that the tests can be written uniformly, + // whether or not 'document' is installed + var mockRPC = params.mockRPC; + _.extend(params, { + mockRPC: function (route, args) { + if (args.model === 'ir.attachment') { + return $.when([]); + } + if (mockRPC) { + return mockRPC.apply(this, arguments); + } + return this._super.apply(this, arguments); + }, + }); + testUtilsMock.addMockEnvironment(widget, _.defaults(params, { debounce: false })); + widget.prependTo($target); + widget.$el.addClass('o_web_client'); + if (config.device.isMobile) { + widget.$el.addClass('o_touch_device'); + } + + var userContext = params.context && params.context.user_context || {}; + var actionManager = new ActionManager(widget, userContext); + + var originalDestroy = ActionManager.prototype.destroy; + actionManager.destroy = function () { + actionManager.destroy = originalDestroy; + widget.destroy(); + }; + actionManager.appendTo(widget.$el); + + return actionManager; +} + +/** + * 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. + * + * Most views can be tested synchronously (@see createView), but some view have + * external dependencies (like lazy loaded libraries). In that case, it is + * necessary to use this method. + * + * @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 {Object} [params.debug=false] if true, the widget will be appended in + * the DOM. Also, RPCs and uncaught OdooEvent will be logged + * @param {string[]} [params.groupBy] the initial groupBy for the view + * @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 + * @returns {Deferred} resolves with the instance of the view + */ +function createAsyncView(params) { + var $target = $('#qunit-fixture'); + var widget = new Widget(); + if (params.debug) { + $target = $('body'); + $target.addClass('debug'); + } + + // add mock environment: mock server, session, fieldviewget, ... + var mockServer = testUtilsMock.addMockEnvironment(widget, params); + var viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); + // create the view + var viewOptions = { + modelName: params.model || 'foo', + ids: 'res_id' in params ? [params.res_id] : undefined, + currentId: 'res_id' in params ? params.res_id : undefined, + domain: params.domain || [], + context: params.context || {}, + groupBy: params.groupBy || [], + }; + if (params.hasSelectors) { + viewOptions.hasSelectors = params.hasSelectors; + } + + _.extend(viewOptions, params.viewOptions); + + var view = new params.View(viewInfo, viewOptions); + + // reproduce the DOM environment of views + var $web_client = $('
').addClass('o_web_client').prependTo($target); + var controlPanel = new ControlPanel(widget); + controlPanel.appendTo($web_client); + var $content = $('
').addClass('o_content').appendTo($web_client); + + if (params.interceptsPropagate) { + _.each(params.interceptsPropagate, function (cb, name) { + testUtilsMock.intercept(widget, name, cb, true); + }); + } + + return view.getController(widget).then(function (view) { + // 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. + view.__destroy = view.destroy; + view.destroy = function () { + // remove the override to properly destroy the view and its children + // when it will be called the second time (by its parent) + delete view.destroy; + widget.destroy(); + }; + + // link the view to the control panel + view.set_cp_bus(controlPanel.get_bus()); + + // render the view in a fragment as they must be able to render correctly + // without being in the DOM + var fragment = document.createDocumentFragment(); + return view.appendTo(fragment).then(function () { + dom.append($content, fragment, { + callbacks: [{ widget: view }], + in_DOM: true, + }); + view.$el.on('click', 'a', function (ev) { + ev.preventDefault(); + }); + + return view; + }); + }); +} + +/** + * Create and return an instance of DebugManager with all rpcs going through a + * mock method, assuming that the user has access rights, and is an admin. + * + * @param {Object} [params={}] + */ +function createDebugManager (params) { + params = params || {}; + var mockRPC = params.mockRPC; + _.extend(params, { + mockRPC: function (route, args) { + if (args.method === 'check_access_rights') { + return $.when(true); + } + if (args.method === 'xmlid_to_res_id') { + return $.when(true); + } + if (mockRPC) { + return mockRPC.apply(this, arguments); + } + return this._super.apply(this, arguments); + }, + session: { + user_has_group: function (group) { + if (group === 'base.group_no_one') { + return $.when(true); + } + return this._super.apply(this, arguments); + }, + }, + }); + var debugManager = new DebugManager(); + testUtilsMock.addMockEnvironment(debugManager, params); + return debugManager; +} + +/** + * 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} + */ +function createModel(params) { + var widget = new Widget(); + + var model = new params.Model(widget); + + 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 {Widget} + */ +function createParent(params) { + var widget = new Widget(); + testUtilsMock.addMockEnvironment(widget, params); + return widget; +} + +/** + * create a view synchronously. This method uses the createAsyncView method. + * Most views are synchronous, so the deferred can be resolved immediately and + * this method will work. + * + * Be careful, if for some reason a view is async, this method will crash. + * @see createAsyncView + * + * @param {Object} params will be given to createAsyncView + * @returns {AbstractView} + */ +function createView(params) { + var view; + createAsyncView(params).then(function (result) { + view = result; + }); + if (!view) { + throw "The view that you are trying to create is async. Please use createAsyncView instead"; + } + return view; +} + + +return { + createActionManager: createActionManager, + createAsyncView: createAsyncView, + createDebugManager: createDebugManager, + createModel: createModel, + createParent: createParent, + createView: createView, +}; + +}); diff --git a/addons/web/static/tests/helpers/test_utils_dom.js b/addons/web/static/tests/helpers/test_utils_dom.js new file mode 100644 index 00000000000..b89f5a8eb5f --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils_dom.js @@ -0,0 +1,235 @@ +odoo.define('web.test_utils_dom', function () { +"use strict"; + +/** + * 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. + */ + + +/** + * 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. + * + * @param {jqueryElement} $el + * @param {jqueryElement} $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) + */ +function dragAndDrop($el, $to, options) { + var position = (options && options.position) || 'center'; + var elementCenter = $el.offset(); + var toOffset = $to.offset(); + + if (_.isObject(position)) { + toOffset.top += position.top; + toOffset.left += position.left; + } else { + toOffset.top += $to.outerHeight() / 2; + toOffset.left += $to.outerWidth() / 2; + var 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 + var bound = $('iframe')[0].getBoundingClientRect(); + toOffset.left += bound.left; + toOffset.top += bound.top; + } + $el.trigger($.Event("mouseenter")); + if (!(options && options.continueMove)) { + elementCenter.left += $el.outerWidth() / 2; + elementCenter.top += $el.outerHeight() / 2; + + $el.trigger($.Event("mousedown", { + which: 1, + pageX: elementCenter.left, + pageY: elementCenter.top + })); + } + + $el.trigger($.Event("mousemove", { + which: 1, + pageX: toOffset.left, + pageY: toOffset.top + })); + + if (!(options && options.disableDrop)) { + $el.trigger($.Event("mouseup", { + which: 1, + pageX: toOffset.left, + pageY: toOffset.top + })); + } 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 () { + $el.trigger($.Event("mouseup")); + }); + } +} + +/** + * 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 {jqueryElement} $el + * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove' + */ +function triggerMouseEvent($el, type) { + var pos = $el.offset(); + var e = new $.Event(type); + e.pageX = e.layerX = e.screenX = pos.left; + e.pageY = e.layerY = e.screenY = pos.top; + e.which = 1; + $el.trigger(e); +} + +/** + * 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' + */ +function triggerPositionalMouseEvent(x, y, type) { + var ev = document.createEvent("MouseEvent"); + var 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 keypress event for a given character + * + * @param {string} char the character, or 'ENTER' + */ +function triggerKeypressEvent(char) { + var keycode; + if (char === "Enter") { + keycode = $.ui.keyCode.ENTER; + } else { + keycode = char.charCodeAt(0); + } + return $('body').trigger($.Event('keypress', { which: keycode, keyCode: keycode })); +} + +/** + * Opens the datepicker of a given element. + * + * @param {jQuery} $datepickerEl element to which a datepicker is attached + */ +function openDatepicker($datepickerEl) { + $datepickerEl.find('.o_datepicker_input').trigger('focus.datetimepicker'); +} + +/** + * 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|NodeList|jQuery} el (if string: it is a (jquery) selector) + * @param {Object} [options] + * @param {boolean} [options.first=false] if true, clicks on the first element + * @param {boolean} [options.last=false] if true, clicks on the last element + */ +function click(el, options) { + options = options || {}; + var matches = typeof el === 'string' ? $(el) : el; + var selectorMsg = el.selector ? `(selector: ${el.selector})` : ''; + if (!matches.filter) { // it might be an array of dom elements + matches = $(matches); + } + + var visibleMatches = matches.filter(':visible'); + if (options.first) { + if (visibleMatches.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'); + } + visibleMatches = visibleMatches.first(); + } else if (options.last) { + if (visibleMatches.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'); + } + visibleMatches = visibleMatches.last(); + } + if (visibleMatches.length === 0 && matches.length > 0) { + throw new Error(`Element to click on is not visible ${selectorMsg}`); + } else if (visibleMatches.length !== 1) { + throw new Error(`Found ${visibleMatches.length} elements to click on, instead of 1 ${selectorMsg}`); + } + visibleMatches.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|NodeList|jQuery} el (if string: it is a (jquery) selector) + */ +function clickFirst(el) { + click(el, {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|NodeList|jQuery} el + */ +function clickLast(el) { + click(el, {last: true}); +} + +return { + triggerKeypressEvent: triggerKeypressEvent, + triggerMouseEvent: triggerMouseEvent, + triggerPositionalMouseEvent: triggerPositionalMouseEvent, + dragAndDrop: dragAndDrop, + openDatepicker: openDatepicker, + click: click, + clickFirst: clickFirst, + clickLast: clickLast, +}; + +}); diff --git a/addons/web/static/tests/helpers/test_utils_fields.js b/addons/web/static/tests/helpers/test_utils_fields.js new file mode 100644 index 00000000000..f7d8829ce82 --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils_fields.js @@ -0,0 +1,172 @@ +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. + */ + +var concurrency = require('web.concurrency'); + +//------------------------------------------------------------------------------ +// Public functions +//------------------------------------------------------------------------------ + +/** + * 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} $el should target an input, textarea or select + * @param {string|number} value + * @param {string[]} events + */ +function editAndTrigger($el, value, events) { + if ($el.length !== 1) { + throw new Error(`target ${$el.selector} has length ${$el.length} instead of 1`); + } + $el.val(value); + events.forEach(function (event) { + $el.trigger(event); + }); +} + +/** + * 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} $el + * @param {string|number} value + */ +function editInput($el, value) { + 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} $el + * @param {string|number} value + */ +function editSelect($el, value) { + editAndTrigger($el, value, ['change']); +} + +/** + * 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 {HTMLInputElement} the main many2one input + */ +function clickOpenM2ODropdown(fieldName, selector) { + var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`; + var matches = document.querySelectorAll(m2oSelector); + if (matches.length !== 1) { + throw new Error(`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`); + } + matches[0].click(); + return matches[0]; +} + +/** + * 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 + */ +function clickM2OHighlightedItem(fieldName, selector) { + var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`; + var $dropdown = $(m2oSelector).autocomplete('widget'); + // clicking on an li (no matter which one), will select the focussed one + $dropdown.find('li:first()').click(); +} + +/** + * 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 + */ +function clickM2OItem(fieldName, searchText) { + var m2oSelector = `.o_field_many2one[name=${fieldName}] input`; + var $dropdown = $(m2oSelector).autocomplete('widget'); + var $target = $dropdown.find(`li:contains(${searchText})`).first(); + if ($target.length !== 1 || !$target.is(':visible')) { + throw new Error('Menu item should be unique and visible'); + } + $target.mouseenter().click(); +} + +/** + * 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 active menuitem + * + * Example: + * testUtils.fields.many2one.searchAndClickM2OItem('partner_id', 'George'); + * + * @param {string} fieldName + * @param {[Object]} options + * @param {[string]} options.selector + * @param {[string]} options.search + * @returns {Promise} + */ +function searchAndClickM2OItem(fieldName, options) { + options = options || {}; + + var input = clickOpenM2ODropdown(fieldName, options.selector); + + var def; + if (options.search) { + // 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 + var $input = $(input); + var delay = $input.autocomplete('option', 'delay'); + $input.autocomplete('option', 'delay', 0); + input.value = options.search; + input.dispatchEvent(new Event('input')); + $input.autocomplete('option', 'delay', delay); + def = concurrency.delay(0); + } + + return $.when(def).then(function () { + clickM2OHighlightedItem(fieldName, options.selector); + }); +} +return { + clickOpenM2ODropdown: clickOpenM2ODropdown, + clickM2OHighlightedItem: clickM2OHighlightedItem, + clickM2OItem: clickM2OItem, + editAndTrigger: editAndTrigger, + editInput: editInput, + editSelect: editSelect, + searchAndClickM2OItem: searchAndClickM2OItem, +}; + +}); diff --git a/addons/web/static/tests/helpers/test_utils_form.js b/addons/web/static/tests/helpers/test_utils_form.js new file mode 100644 index 00000000000..379350e1ca6 --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils_form.js @@ -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) { + 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) { + 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) { + 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) { + 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) { + form.reload(params); +} + +return { + clickEdit: clickEdit, + clickSave: clickSave, + clickCreate: clickCreate, + clickDiscard: clickDiscard, + reload: reload, +}; + +}); diff --git a/addons/web/static/tests/helpers/test_utils_graph.js b/addons/web/static/tests/helpers/test_utils_graph.js new file mode 100644 index 00000000000..8867f82c349 --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils_graph.js @@ -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) { + graph.reload(params); +} + +return { + reload: reload, +}; + +}); diff --git a/addons/web/static/tests/helpers/test_utils_kanban.js b/addons/web/static/tests/helpers/test_utils_kanban.js new file mode 100644 index 00000000000..166ae2a2572 --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils_kanban.js @@ -0,0 +1,97 @@ +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 + */ +function clickCreate(kanban) { + testUtilsDom.click(kanban.$buttons.find('.o-kanban-button-new')); +} + +/** + * Open the settings menu for a column (in a grouped kanban view) + * + * @param {jQuery} $column + */ +function toggleGroupSettings($column) { + var $dropdownToggler = $column.find('.o_kanban_config > a.dropdown-toggle'); + if (!$dropdownToggler.is(':visible')) { + $dropdownToggler.css('display', 'block'); + } + 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 + */ +function quickCreate(kanban, value, fieldName) { + var additionalSelector = fieldName ? ('[name=' + fieldName + ']'): ''; + var enterEvent = $.Event( + 'keydown', + { + which: $.ui.keyCode.ENTER, + keyCode: $.ui.keyCode.ENTER, + } + ); + 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 + */ +function reload(kanban, params) { + 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 + */ +function toggleRecordDropdown($record) { + var $dropdownToggler = $record.find('.o_dropdown_kanban > a.dropdown-toggle'); + if (!$dropdownToggler.is(':visible')) { + $dropdownToggler.css('display', 'block'); + } + testUtilsDom.click($dropdownToggler); +} + + +return { + clickCreate: clickCreate, + quickCreate: quickCreate, + reload: reload, + toggleGroupSettings: toggleGroupSettings, + toggleRecordDropdown: toggleRecordDropdown, +}; + +}); diff --git a/addons/web/static/tests/helpers/test_utils_mock.js b/addons/web/static/tests/helpers/test_utils_mock.js new file mode 100644 index 00000000000..769c179472a --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils_mock.js @@ -0,0 +1,545 @@ +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. + */ + +var basic_fields = require('web.basic_fields'); +var config = require('web.config'); +var core = require('web.core'); +var MockServer = require('web.MockServer'); +var session = require('web.session'); + +var DebouncedField = basic_fields.DebouncedField; + +//------------------------------------------------------------------------------ +// Private functions +//------------------------------------------------------------------------------ + +/** + * 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 +//------------------------------------------------------------------------------ + +/** + * Removes the src attribute on images and iframes to prevent not found errors, + * and optionally triggers an rpc with the src url as route on a widget. + * This method is critical and must be fastest (=> no jQuery, no underscore) + * + * @param {DOM Node} el + * @param {[Widget]} widget the widget on which the rpc should be performed + */ +function removeSrcAttribute(el, widget) { + var nodes; + if (el.nodeName === 'IMG' || el.nodeName === 'IFRAME') { + nodes = [el]; + } else { + nodes = Array.prototype.slice.call(el.getElementsByTagName('img')) + .concat(Array.prototype.slice.call(el.getElementsByTagName('iframe'))); + } + var node; + while (node = nodes.pop()) { + var src = node.attributes.src && node.attributes.src.value; + if (src && src !== 'about:blank') { + node.setAttribute('data-src', src); + if (node.nodeName === 'IMG') { + node.attributes.removeNamedItem('src'); + } else { + node.setAttribute('src', 'about:blank'); + } + if (widget) { + widget._rpc({ route: src }); + } + } + } +} + +/** + * 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 {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 {MockServer} the instance of the mock server, created by this + * function. It is necessary for createAsyncView so that method can call some + * other methods on it. + */ +function addMockEnvironment(widget, params) { + var Server = MockServer; + params.services = params.services || {}; + if (params.mockRPC) { + Server = MockServer.extend({ _performRpc: params.mockRPC }); + } + 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); + } + + var mockServer = new Server(params.data, { + actions: params.actions, + archs: params.archs, + currentDate: params.currentDate, + debug: params.debug, + widget: widget, + }); + + // make sure images do not trigger a GET on the server + $('body').on('DOMNodeInserted.removeSRC', function (event) { + removeSrcAttribute(event.target, widget); + }); + + // make sure the debounce value for input fields is set to 0 + var initialDebounceValue = DebouncedField.prototype.DEBOUNCE; + DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0; + var initialSession, initialConfig, initialParameters, initialDebounce, initialThrottle; + initialSession = _.extend({}, session); + session.getTZOffset = function () { + return 0; // by default, but may be overriden in specific tests + }; + if ('session' in params) { + _.extend(session, params.session); + } + if ('config' in params) { + initialConfig = _.clone(config); + initialConfig.device = _.clone(config.device); + if ('device' in params.config) { + _.extend(config.device, params.config.device); + } + if ('debug' in params.config) { + config.debug = params.config.debug; + } + } + if ('translateParameters' in params) { + initialParameters = _.extend({}, core._t.database.parameters); + _.extend(core._t.database.parameters, params.translateParameters); + } + if (params.debounce === false) { + initialDebounce = _.debounce; + _.debounce = function (func) { + return func; + }; + } + if (!('throttle' in params) || !params.throttle) { + initialThrottle = _.throttle; + _.throttle = function (func) { + return func; + }; + } + + var widgetDestroy = widget.destroy; + widget.destroy = function () { + // clear the caches (e.g. data_manager, ModelFieldSelector) when the + // widget is destroyed, at the end of each test to avoid collisions + core.bus.trigger('clear_cache'); + + DebouncedField.prototype.DEBOUNCE = initialDebounceValue; + if (params.debounce === false) { + _.debounce = initialDebounce; + } + if (!('throttle' in params) || !params.throttle) { + _.throttle = initialThrottle; + } + + var key; + if ('session' in params) { + for (key in session) { + delete session[key]; + } + } + _.extend(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); + } + + $('body').off('DOMNodeInserted.removeSRC'); + $('.blockUI').remove(); + + widgetDestroy.call(this); + }; + + // Dispatch service calls + // Note: some services could call other services at init, + // Which is why we have to init services after that + var services = {}; + intercept(widget, 'call_service', function (ev) { + var args, result; + if (services[ev.data.service]) { + var service = services[ev.data.service]; + args = (ev.data.args || []); + result = service[ev.data.method].apply(service, args); + } else if (ev.data.service === 'ajax') { + // use ajax service that is mocked by the server + var route = ev.data.args[0]; + args = ev.data.args[1]; + result = mockServer.performRpc(route, args); + } + ev.data.callback(result); + }); + + intercept(widget, 'load_action', function (event) { + mockServer.performRpc('/web/action/load', { + kwargs: { + action_id: event.data.actionID, + additional_context: event.data.context, + }, + }).then(function (action) { + event.data.on_success(action); + }); + }); + + intercept(widget, "load_views", function (event) { + mockServer.performRpc('/web/dataset/call_kw/' + event.data.modelName, { + args: [], + kwargs: { + context: event.data.context, + options: event.data.options, + views: event.data.views, + }, + method: 'load_views', + model: event.data.modelName, + }).then(function (views) { + views = _.mapObject(views, function (viewParams) { + return fieldsViewGet(mockServer, viewParams); + }); + event.data.on_success(views); + }); + }); + + intercept(widget, "get_session", function (event) { + event.data.callback(session); + }); + + intercept(widget, "load_filters", function (event) { + if (params.debug) { + console.log('[mock] load_filters', event.data); + } + event.data.on_success([]); + }); + + // make sure all Odoo events bubbling up are intercepted + if ('intercepts' in params) { + _.each(params.intercepts, function (cb, name) { + intercept(widget, name, cb); + }); + } + + // Deploy services + var done = false; + var servicesToDeploy = _.clone(params.services); + if (!servicesToDeploy.ajax) { + services.ajax = null; // use mocked ajax from mocked server + } + while (!done) { + var serviceName = _.findKey(servicesToDeploy, function (Service) { + return !_.some(Service.prototype.dependencies, function (depName) { + return !_.has(services, depName); + }); + }); + if (serviceName) { + var Service = servicesToDeploy[serviceName]; + var service = services[serviceName] = new Service(widget); + delete servicesToDeploy[serviceName]; + + intercept(service, "get_session", function (event) { + event.data.callback(session); + }); + + service.start(); + } else { + var serviceNames = _.keys(servicesToDeploy); + if (serviceNames.length) { + console.warn("Non loaded services:", serviceNames); + } + done = true; + } + } + + return mockServer; +} + +/** + * performs a fields_view_get, 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 fieldsViewGet(server, params) { + var fieldsView = server.fieldsViewGet(params); + // mock the structure produced by the DataManager + fieldsView.viewFields = fieldsView.fields; + fieldsView.fields = server.fieldsGet(params.model); + return fieldsView; +} + +/** + * 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); + }; +} + +/** + * Patch window.Date so that the time starts its flow from the provided Date. + * + * Usage: + * + * ``` + * var unpatchDate = 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)" + * ... + * unpatchDate(); + * new window.Date(); // actual current date time + * ``` + * + * @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} a callback to unpatch window.Date. + */ +function patchDate(year, month, day, hours, minutes, seconds) { + var RealDate = window.Date; + var actualDate = new RealDate(); + var fakeDate = new RealDate(year, month, day, hours, minutes, seconds); + var timeInterval = actualDate.getTime() - (fakeDate.getTime()); + + Date = (function (NativeDate) { + function Date(Y, M, D, h, m, s, ms) { + var length = arguments.length; + if (arguments.length > 0) { + var date = length == 1 && String(Y) === Y ? // isString(Y) + // We explicitly pass it through parse: + new NativeDate(Date.parse(Y)) : + // We have to manually make calls depending on argument + // length here + length >= 7 ? new NativeDate(Y, M, D, h, m, s, ms) : + length >= 6 ? new NativeDate(Y, M, D, h, m, s) : + length >= 5 ? new NativeDate(Y, M, D, h, m) : + length >= 4 ? new NativeDate(Y, M, D, h) : + length >= 3 ? new NativeDate(Y, M, D) : + length >= 2 ? new NativeDate(Y, M) : + length >= 1 ? new NativeDate(Y) : + new NativeDate(); + // Prevent mixups with unfixed Date object + date.constructor = Date; + return date; + } else { + var date = new NativeDate(); + var time = date.getTime(); + time -= timeInterval; + date.setTime(time); + return date; + } + } + + // Copy any custom methods a 3rd party library may have added + for (var key in NativeDate) { + Date[key] = NativeDate[key]; + } + + // Copy "native" methods explicitly; they may be non-enumerable + Date.now = NativeDate.now; + Date.UTC = NativeDate.UTC; + Date.prototype = NativeDate.prototype; + Date.prototype.constructor = Date; + + // Upgrade Date.parse to handle simplified ISO 8601 strings + Date.parse = NativeDate.parse; + return Date; + })(Date); + + return function () { window.Date = RealDate; }; +} + +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; +} + + +return { + addMockEnvironment: addMockEnvironment, + fieldsViewGet: fieldsViewGet, + intercept: intercept, + patchDate: patchDate, + patch: patch, + unpatch: unpatch, +}; + +}); diff --git a/addons/web/static/tests/helpers/test_utils_modal.js b/addons/web/static/tests/helpers/test_utils_modal.js new file mode 100644 index 00000000000..d2162aebfa0 --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils_modal.js @@ -0,0 +1,34 @@ +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. + */ + +var core = require('web.core'); + +/** + * Click on a button in the footer of a modal (which contains a given string). + * Note that this method checks the unicity of the button. + * + * @param {string} text (in english: this method will perform the translation) + */ +function clickButton(text) { + var selector = `.modal-footer button:contains(${core._t(text)})`; + var $button = $(selector); + if ($button.length !== 1) { + throw new Error(`Found ${$button.length} button(s) containing '${text}'`); + } + $button.click(); +} + +return { + clickButton: clickButton, +}; + +}); diff --git a/addons/web/static/tests/helpers/test_utils_pivot.js b/addons/web/static/tests/helpers/test_utils_pivot.js new file mode 100644 index 00000000000..18a5a7307f3 --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils_pivot.js @@ -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) { + 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) { + 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) { + pivot.reload(params); +} + +return { + clickMeasure: clickMeasure, + reload: reload, + toggleMeasuresDropdown: toggleMeasuresDropdown, +}; + +}); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 4e9c32d5385..e5c5bd22979 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -489,6 +489,15 @@ } + + + + + + + + +