vanilla 18.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:48:09 +02:00
parent 5454004ff9
commit d7f6d2725e
979 changed files with 428093 additions and 0 deletions

View file

@ -0,0 +1,296 @@
/** @odoo-module alias=@web/../tests/core/condition_tree_editor_helpers default=false */
import { getNodesTextContent, editInput, click, editSelect } from "../helpers/utils";
import { fieldService } from "@web/core/field_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import { ormService } from "@web/core/orm_service";
import { popoverService } from "@web/core/popover/popover_service";
import { uiService } from "@web/core/ui/ui_service";
import { nameService } from "@web/core/name_service";
import { dialogService } from "@web/core/dialog/dialog_service";
import { datetimePickerService } from "@web/core/datetime/datetimepicker_service";
import { registry } from "@web/core/registry";
import { notificationService } from "@web/core/notifications/notification_service";
export function setupConditionTreeEditorServices() {
registry.category("services").add("popover", popoverService);
registry.category("services").add("orm", ormService);
registry.category("services").add("ui", uiService);
registry.category("services").add("hotkey", hotkeyService);
registry.category("services").add("localization", makeFakeLocalizationService());
registry.category("services").add("field", fieldService);
registry.category("services").add("name", nameService);
registry.category("services").add("dialog", dialogService);
registry.category("services").add("datetime_picker", datetimePickerService);
registry.category("services").add("notification", notificationService);
}
export function makeServerData() {
const serverData = {
models: {
partner: {
fields: {
foo: { string: "Foo", type: "char", searchable: true },
bar: { string: "Bar", type: "boolean", searchable: true },
product_id: {
string: "Product",
type: "many2one",
relation: "product",
searchable: true,
},
date: { string: "Date", type: "date", searchable: true },
datetime: { string: "Date Time", type: "datetime", searchable: true },
int: { string: "Integer", type: "integer", searchable: true },
json_field: { string: "Json Field", type: "json", searchable: true },
state: {
string: "State",
type: "selection",
selection: [
["abc", "ABC"],
["def", "DEF"],
["ghi", "GHI"],
],
},
},
records: [
{ id: 1, foo: "yop", bar: true, product_id: 37 },
{ id: 2, foo: "blip", bar: true, product_id: false },
{ id: 4, foo: "abc", bar: false, product_id: 41 },
],
onchanges: {},
},
product: {
fields: {
name: { string: "Product Name", type: "char", searchable: true },
},
records: [
{ id: 37, display_name: "xphone" },
{ id: 41, display_name: "xpad" },
],
},
},
};
return serverData;
}
////////////////////////////////////////////////////////////////////////////////
export const SELECTORS = {
node: ".o_tree_editor_node",
row: ".o_tree_editor_row",
tree: ".o_tree_editor > .o_tree_editor_node",
connector: ".o_tree_editor_connector",
condition: ".o_tree_editor_condition",
addNewRule: ".o_tree_editor_row > a",
buttonAddNewRule: ".o_tree_editor_node_control_panel > button:nth-child(1)",
buttonAddBranch: ".o_tree_editor_node_control_panel > button:nth-child(2)",
buttonDeleteNode: ".o_tree_editor_node_control_panel > button:nth-child(3)",
pathEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(1)",
operatorEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(2)",
valueEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(3)",
editor: ".o_tree_editor_editor",
clearNotSupported: ".o_input .fa-times",
tag: ".o_input .o_tag",
toggleArchive: ".form-switch",
complexCondition: ".o_tree_editor_complex_condition",
complexConditionInput: ".o_tree_editor_complex_condition input",
};
const CHILD_SELECTOR = ["connector", "condition", "complexCondition"]
.map((k) => SELECTORS[k])
.join(",");
export function getTreeEditorContent(target, options = {}) {
const content = [];
const nodes = target.querySelectorAll(SELECTORS.node);
const mapping = new Map();
for (const node of nodes) {
const parent = node.parentElement.closest(SELECTORS.node);
const level = parent ? mapping.get(parent) + 1 : 0;
mapping.set(node, level);
const nodeValue = { level };
const associatedNode = node.querySelector(CHILD_SELECTOR);
const className = associatedNode.className;
if (className.includes("connector")) {
nodeValue.value = getCurrentConnector(node);
} else if (className.includes("complex_condition")) {
nodeValue.value = getCurrentComplexCondition(node);
} else {
nodeValue.value = getCurrentCondition(node);
}
if (options.node) {
nodeValue.node = node;
}
content.push(nodeValue);
}
return content;
}
export function get(target, selector, index = 0) {
if (index) {
return [...target.querySelectorAll(selector)].at(index);
}
return target.querySelector(selector);
}
function getValue(target) {
if (target) {
const el = target.querySelector("input,select,span:not(.o_tag)");
switch (el.tagName) {
case "INPUT":
return el.value;
case "SELECT":
return el.options[el.selectedIndex].label;
case "SPAN":
return el.innerText;
}
}
}
export function getCurrentPath(target, index = 0) {
const pathEditor = get(target, SELECTORS.pathEditor, index);
if (pathEditor) {
if (pathEditor.querySelector(".o_model_field_selector")) {
return getModelFieldSelectorValues(pathEditor).join(" > ");
}
return pathEditor.textContent;
}
}
export function getCurrentOperator(target, index = 0) {
const operatorEditor = get(target, SELECTORS.operatorEditor, index);
return getValue(operatorEditor);
}
export function getCurrentValue(target, index) {
const valueEditor = get(target, SELECTORS.valueEditor, index);
const value = getValue(valueEditor);
if (valueEditor) {
const tags = [...valueEditor.querySelectorAll(".o_tag")];
if (tags.length) {
let text = `${tags.map((t) => t.innerText).join(" ")}`;
if (value) {
text += ` ${value}`;
}
return text;
}
}
return value;
}
export function getOperatorOptions(target, index = 0) {
const el = get(target, SELECTORS.operatorEditor, index);
if (el) {
const select = el.querySelector("select");
return [...select.options].map((o) => o.label);
}
}
export function getValueOptions(target, index = 0) {
const el = get(target, SELECTORS.valueEditor, index);
if (el) {
const select = el.querySelector("select");
return [...select.options].map((o) => o.label);
}
}
function getCurrentComplexCondition(target, index = 0) {
const input = get(target, SELECTORS.complexConditionInput, index);
return input?.value;
}
export function getConditionText(target, index = 0) {
const condition = get(target, SELECTORS.condition, index);
if (condition) {
const texts = [];
for (const t of getNodesTextContent(condition.childNodes)) {
const t2 = t.trim();
if (t2) {
texts.push(t2);
}
}
return texts.join(" ");
}
}
function getCurrentCondition(target, index = 0) {
const values = [getCurrentPath(target, index), getCurrentOperator(target, index)];
const valueEditor = get(target, SELECTORS.valueEditor, index);
if (valueEditor) {
values.push(getCurrentValue(target, index));
}
return values;
}
function getCurrentConnector(target, index = 0) {
const connector = get(
target,
`${SELECTORS.connector} .dropdown-toggle, ${SELECTORS.connector} > span:nth-child(2), ${SELECTORS.connector} > span > strong`,
index
);
return connector?.textContent.search("all") >= 0 ? "all" : connector?.textContent;
}
////////////////////////////////////////////////////////////////////////////////
export function isNotSupportedPath(target, index = 0) {
const pathEditor = get(target, SELECTORS.pathEditor, index);
return Boolean(pathEditor.querySelector(SELECTORS.clearNotSupported));
}
export function isNotSupportedOperator(target, index = 0) {
const operatorEditor = get(target, SELECTORS.operatorEditor, index);
return Boolean(operatorEditor.querySelector(SELECTORS.clearNotSupported));
}
export function isNotSupportedValue(target, index = 0) {
const valueEditor = get(target, SELECTORS.valueEditor, index);
return Boolean(valueEditor.querySelector(SELECTORS.clearNotSupported));
}
////////////////////////////////////////////////////////////////////////////////
export async function selectOperator(target, operator, index = 0) {
const el = get(target, SELECTORS.operatorEditor, index);
await editSelect(el, "select", JSON.stringify(operator));
}
export async function selectValue(target, value, index = 0) {
const el = get(target, SELECTORS.valueEditor, index);
await editSelect(el, "select", JSON.stringify(value));
}
export async function editValue(target, value, index = 0) {
const el = get(target, SELECTORS.valueEditor, index);
await editInput(el, "input", value);
}
export async function clickOnButtonAddNewRule(target, index = 0) {
await click(get(target, SELECTORS.buttonAddNewRule, index));
}
export async function clickOnButtonAddBranch(target, index = 0) {
await click(get(target, SELECTORS.buttonAddBranch, index));
}
export async function clickOnButtonDeleteNode(target, index = 0) {
await click(get(target, SELECTORS.buttonDeleteNode, index));
}
export async function clearNotSupported(target, index = 0) {
await click(get(target, SELECTORS.clearNotSupported, index));
}
export async function addNewRule(target, index = 0) {
await click(get(target, SELECTORS.addNewRule, index));
}
export async function toggleArchive(target) {
await click(target, SELECTORS.toggleArchive);
}
////////////////////////////////////////////////////////////////////////////////
function getModelFieldSelectorValues(target) {
return getNodesTextContent(target.querySelectorAll("span.o_model_field_selector_chain_part"));
}

View file

@ -0,0 +1,216 @@
/** @odoo-module alias=@web/../tests/core/datetime/datetime_test_helpers default=false */
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { localization } from "@web/core/l10n/localization";
import { ensureArray } from "@web/core/utils/arrays";
import { click, getFixture } from "../../helpers/utils";
/**
* @typedef {import("@web/core/datetime/datetime_picker").DateTimePickerProps} DateTimePickerProps
*/
/**
* @param {false | {
* title?: string | string[],
* date?: {
* cells: (number | string | [number] | [string])[][],
* daysOfWeek?: string[],
* weekNumbers?: number[],
* }[],
* time?: ([number, number] | [number, number, "AM" | "PM"])[],
* }} expectedParams
*/
export function assertDateTimePicker(expectedParams) {
const assert = QUnit.assert;
const fixture = getFixture();
// Check for picker in DOM
if (expectedParams) {
assert.containsOnce(fixture, ".o_datetime_picker");
} else {
assert.containsNone(fixture, ".o_datetime_picker");
return;
}
const { title, date, time } = expectedParams;
// Title
if (title) {
const expectedTitle = ensureArray(title);
assert.containsOnce(fixture, ".o_datetime_picker_header");
assert.deepEqual(
getTexts(".o_datetime_picker_header", "strong"),
expectedTitle,
`title should be "${expectedTitle.join(" - ")}"`
);
} else {
assert.containsNone(fixture, ".o_datetime_picker_header");
}
// Time picker
if (time) {
assert.containsN(fixture, ".o_time_picker", time.length);
const timePickers = select(".o_time_picker");
for (let i = 0; i < time.length; i++) {
const expectedTime = time[i];
const values = select(timePickers[i], ".o_time_picker_select").map((sel) => sel.value);
const actual = [...values.slice(0, 2).map(Number), ...values.slice(2)];
assert.deepEqual(actual, expectedTime, `time values should be [${expectedTime}]`);
}
} else {
assert.containsNone(fixture, ".o_time_picker");
}
// Date picker
const datePickerEls = select(".o_date_picker");
assert.containsN(fixture, ".o_date_picker", date.length);
let selectedCells = 0;
let outOfRangeCells = 0;
let todayCells = 0;
for (let i = 0; i < date.length; i++) {
const { cells, daysOfWeek, weekNumbers } = date[i];
const datePickerEl = datePickerEls[i];
const cellEls = select(datePickerEl, ".o_date_item_cell");
assert.strictEqual(
cellEls.length,
PICKER_ROWS * PICKER_COLS,
`picker should have ${
PICKER_ROWS * PICKER_COLS
} cells (${PICKER_ROWS} rows and ${PICKER_COLS} columns)`
);
if (daysOfWeek) {
const actualDow = getTexts(datePickerEl, ".o_day_of_week_cell");
assert.deepEqual(
actualDow,
daysOfWeek,
`picker should display the days of week: ${daysOfWeek
.map((dow) => `"${dow}"`)
.join(", ")}`
);
}
if (weekNumbers) {
assert.deepEqual(
getTexts(datePickerEl, ".o_week_number_cell").map(Number),
weekNumbers,
`picker should display the week numbers (${weekNumbers.join(", ")})`
);
}
// Date cells
const expectedCells = cells.flatMap((row, rowIndex) =>
row.map((cell, colIndex) => {
const cellEl = cellEls[rowIndex * PICKER_COLS + colIndex];
// Check flags
let value = cell;
const isSelected = Array.isArray(cell);
if (isSelected) {
value = value[0];
}
const isToday = typeof value === "string";
if (isToday) {
value = Number(value);
}
const isOutOfRange = value < 0;
if (isOutOfRange) {
value = Math.abs(value);
}
// Assert based on flags
if (isSelected) {
selectedCells++;
assert.hasClass(cellEl, "o_selected");
}
if (isOutOfRange) {
outOfRangeCells++;
assert.hasClass(cellEl, "o_out_of_range");
}
if (isToday) {
todayCells++;
assert.hasClass(cellEl, "o_today");
}
return value;
})
);
assert.deepEqual(
cellEls.map((cell) => Number(getTexts(cell)[0])),
expectedCells,
`cell content should match the expected values: [${expectedCells.join(", ")}]`
);
}
assert.containsN(fixture, ".o_selected", selectedCells);
assert.containsN(fixture, ".o_out_of_range", outOfRangeCells);
assert.containsN(fixture, ".o_today", todayCells);
}
export function getPickerApplyButton() {
return select(".o_datetime_picker .o_datetime_buttons .o_apply").at(0);
}
/**
* @param {RegExp | string} expr
*/
export function getPickerCell(expr) {
const regex = expr instanceof RegExp ? expr : new RegExp(`^${expr}$`, "i");
const cells = select(".o_datetime_picker .o_date_item_cell").filter((cell) =>
regex.test(getTexts(cell)[0])
);
return cells.length === 1 ? cells[0] : cells;
}
/**
* @param {...(string | HTMLElement)} selectors
* @returns {string[]}
*/
export function getTexts(...selectors) {
return select(...selectors).map((e) => e.innerText.trim().replace(/\s+/g, " "));
}
/**
* @param {Object} [options={}]
* @param {boolean} [options.parse=false] whether to directly return the parsed
* values of the select elements
* @returns {HTMLSelectElement[] | (number | string)[]}
*/
export function getTimePickers({ parse = false } = {}) {
return select(".o_time_picker").map((timePickerEl) => {
const selects = select(timePickerEl, ".o_time_picker_select");
if (parse) {
return selects.map((sel) => (isNaN(sel.value) ? sel.value : Number(sel.value)));
} else {
return selects;
}
});
}
/**
* @param {...(string | HTMLElement)} selectors
* @returns {HTMLElement[]}
*/
const select = (...selectors) => {
const root = selectors[0] instanceof Element ? selectors.shift() : getFixture();
return selectors.length ? [...root.querySelectorAll(selectors.join(" "))] : [root];
};
export function useTwelveHourClockFormat() {
const { dateFormat = "dd/MM/yyyy", timeFormat = "HH:mm:ss" } = localization;
const twcTimeFormat = `${timeFormat.replace(/H/g, "h")} a`;
patchWithCleanup(localization, {
dateTimeFormat: `${dateFormat} ${twcTimeFormat}`,
timeFormat: twcTimeFormat,
});
}
export function zoomOut() {
return click(getFixture(), ".o_zoom_out");
}
const PICKER_ROWS = 6;
const PICKER_COLS = 7;

View file

@ -0,0 +1,31 @@
/** @odoo-module alias=@web/../tests/core/domain_selector_tests default=false */
import {
SELECTORS as treeEditorSELECTORS,
} from "./condition_tree_editor_helpers";
export {
addNewRule,
clearNotSupported,
clickOnButtonAddBranch,
clickOnButtonAddNewRule,
clickOnButtonDeleteNode,
editValue,
getConditionText,
getCurrentOperator,
getCurrentPath,
getCurrentValue,
getOperatorOptions,
isNotSupportedOperator,
isNotSupportedPath,
isNotSupportedValue,
selectOperator,
selectValue,
toggleArchive,
} from "./condition_tree_editor_helpers";
export const SELECTORS = {
...treeEditorSELECTORS,
debugArea: ".o_domain_selector_debug_container textarea",
resetButton: ".o_domain_selector_row > button",
};

View file

@ -0,0 +1,55 @@
/** @odoo-module alias=@web/../tests/core/utils/nested_sortable_tests default=false */
import { drag, getFixture } from "@web/../tests/helpers/utils";
/**
* Dragging methods taking into account the fact that it's the top of the
* dragged element that triggers the moves (not the position of the cursor),
* and the fact that during the first move, the dragged element is replaced by
* a placeholder that does not have the same height. The moves are done with
* the same x position to prevent triggering horizontal moves.
* @param {string} from
*/
export const sortableDrag = async (from) => {
const fixture = getFixture();
const fromEl = fixture.querySelector(from);
const fromRect = fromEl.getBoundingClientRect();
const { drop, moveTo } = await drag(from);
let isFirstMove = true;
/**
* @param {string} [targetSelector]
*/
const moveAbove = async (targetSelector) => {
const el = fixture.querySelector(targetSelector);
await moveTo(el, {
x: fromRect.x - el.getBoundingClientRect().x + fromRect.width / 2,
y: fromRect.height / 2 + 5,
});
isFirstMove = false;
};
/**
* @param {string} [targetSelector]
*/
const moveUnder = async (targetSelector) => {
const el = fixture.querySelector(targetSelector);
const elRect = el.getBoundingClientRect();
let firstMoveBelow = false;
if (isFirstMove && elRect.y > fromRect.y) {
// Need to consider that the moved element will be replaced by a
// placeholder with a height of 5px
firstMoveBelow = true;
}
await moveTo(el, {
x: fromRect.x - elRect.x + fromRect.width / 2,
y:
((firstMoveBelow ? -1 : 1) * fromRect.height) / 2 +
elRect.height +
(firstMoveBelow ? 4 : -1),
});
isFirstMove = false;
};
return { moveAbove, moveUnder, drop };
};

View file

@ -0,0 +1,114 @@
/** @odoo-module alias=@web/../tests/helpers/cleanup default=false */
// -----------------------------------------------------------------------------
// Cleanup
// -----------------------------------------------------------------------------
const cleanups = [];
/**
* Register a cleanup callback that will be executed whenever the current test
* is done.
*
* - the cleanups will be executed in reverse order
* - they will be executed even if the test fails/crashes
*
* @param {Function} callback
*/
export function registerCleanup(callback) {
cleanups.push(callback);
}
if (window.QUnit) {
QUnit.on("OdooAfterTestHook", (info) => {
if (QUnit.config.debug) {
return;
}
let cleanup;
// note that this calls the cleanup callbacks in reverse order!
while ((cleanup = cleanups.pop())) {
try {
cleanup(info);
} catch (error) {
console.error(error);
}
}
});
// -----------------------------------------------------------------------------
// Check leftovers
// -----------------------------------------------------------------------------
/**
* List of elements tolerated in the body after a test. The property "keep"
* prevents the element from being removed (typically: qunit suite elements).
*/
const validElements = [
// always in the body:
{ tagName: "DIV", attr: "id", value: "qunit", keep: true },
{ tagName: "DIV", attr: "id", value: "qunit-fixture", keep: true },
// shouldn't be in the body after a test but are tolerated:
{ tagName: "SCRIPT", attr: "id", value: "" },
{ tagName: "DIV", attr: "class", value: "o_notification_manager" },
{ tagName: "DIV", attr: "class", value: "tooltip fade bs-tooltip-auto" },
{ tagName: "DIV", attr: "class", value: "tooltip fade bs-tooltip-auto show" },
{ tagName: "DIV", attr: "class", value: "tooltip tooltip-field-info fade bs-tooltip-auto" },
{
tagName: "DIV",
attr: "class",
value: "tooltip tooltip-field-info fade bs-tooltip-auto show",
},
// Due to a Document Kanban bug (already present in 12.0)
{ tagName: "DIV", attr: "class", value: "ui-helper-hidden-accessible" },
{
tagName: "UL",
attr: "class",
value: "ui-menu ui-widget ui-widget-content ui-autocomplete ui-front",
},
{
tagName: "UL",
attr: "class",
value: "ui-menu ui-widget ui-widget-content ui-autocomplete dropdown-menu ui-front", // many2ones
},
];
/**
* After each test, we check that there is no leftover in the DOM.
*
* Note: this event is not QUnit standard, we added it for this specific use case.
* As a payload, an object with keys 'moduleName' and 'testName' is provided. It
* is used to indicate the test that left elements in the DOM, when it happens.
*/
QUnit.on("OdooAfterTestHook", function (info) {
if (QUnit.config.debug) {
return;
}
const failed = info.testReport.getStatus() === "failed";
const toRemove = [];
// check for leftover elements in the body
for (const bodyChild of document.body.children) {
const tolerated = validElements.find(
(e) => e.tagName === bodyChild.tagName && bodyChild.getAttribute(e.attr) === e.value
);
if (!failed && !tolerated) {
QUnit.pushFailure(
`Body still contains undesirable elements:\n${bodyChild.outerHTML}`
);
}
if (!tolerated || !tolerated.keep) {
toRemove.push(bodyChild);
}
}
// cleanup leftovers in #qunit-fixture
const qunitFixture = document.getElementById("qunit-fixture");
if (qunitFixture.children.length) {
toRemove.push(...qunitFixture.children);
}
// remove unwanted elements if not in debug
for (const el of toRemove) {
el.remove();
}
document.body.classList.remove("modal-open");
});
}

View file

@ -0,0 +1,150 @@
/** @odoo-module alias=@web/../tests/helpers/mock_env default=false */
import { SERVICES_METADATA } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { makeEnv, startServices } from "@web/env";
import { registerCleanup } from "./cleanup";
import { makeMockServer } from "./mock_server";
import { mocks } from "./mock_services";
import { patchWithCleanup } from "./utils";
import { Component } from "@odoo/owl";
import { startRouter } from "@web/core/browser/router";
function prepareRegistry(registry, keepContent = false) {
const _addEventListener = registry.addEventListener.bind(registry);
const _removeEventListener = registry.removeEventListener.bind(registry);
const patch = {
content: keepContent ? { ...registry.content } : {},
elements: null,
entries: null,
subRegistries: {},
addEventListener(type, callback) {
_addEventListener(type, callback);
registerCleanup(() => {
_removeEventListener(type, callback);
});
},
};
patchWithCleanup(registry, patch);
}
export function clearRegistryWithCleanup(registry) {
prepareRegistry(registry);
}
function cloneRegistryWithCleanup(registry) {
prepareRegistry(registry, true);
}
export function clearServicesMetadataWithCleanup() {
const servicesMetadata = Object.assign({}, SERVICES_METADATA);
for (const key of Object.keys(SERVICES_METADATA)) {
delete SERVICES_METADATA[key];
}
registerCleanup(() => {
for (const key of Object.keys(SERVICES_METADATA)) {
delete SERVICES_METADATA[key];
}
Object.assign(SERVICES_METADATA, servicesMetadata);
});
}
export const registryNamesToCloneWithCleanup = [
"actions",
"command_provider",
"command_setup",
"error_handlers",
"fields",
"fields",
"main_components",
"view_widgets",
"views",
];
export const utils = {
prepareRegistriesWithCleanup() {
// Clone registries
registryNamesToCloneWithCleanup.forEach((registryName) =>
cloneRegistryWithCleanup(registry.category(registryName))
);
// Clear registries
clearRegistryWithCleanup(registry.category("command_categories"));
clearRegistryWithCleanup(registry.category("debug"));
clearRegistryWithCleanup(registry.category("error_dialogs"));
clearRegistryWithCleanup(registry.category("favoriteMenu"));
clearRegistryWithCleanup(registry.category("ir.actions.report handlers"));
clearRegistryWithCleanup(registry.category("main_components"));
clearRegistryWithCleanup(registry.category("services"));
clearServicesMetadataWithCleanup();
clearRegistryWithCleanup(registry.category("systray"));
clearRegistryWithCleanup(registry.category("user_menuitems"));
clearRegistryWithCleanup(registry.category("kanban_examples"));
clearRegistryWithCleanup(registry.category("__processed_archs__"));
// fun fact: at least one registry is missing... this shows that we need a
// better design for the way we clear these registries...
},
};
// This is exported in a utils object to allow for patching
export function prepareRegistriesWithCleanup() {
return utils.prepareRegistriesWithCleanup();
}
/**
* @typedef {import("@web/env").OdooEnv} OdooEnv
*/
/**
* Create a test environment
*
* @param {*} config
* @returns {Promise<OdooEnv>}
*/
export async function makeTestEnv(config = {}) {
startRouter();
// add all missing dependencies if necessary
const serviceRegistry = registry.category("services");
const servicesToProcess = serviceRegistry.getAll();
while (servicesToProcess.length) {
const service = servicesToProcess.pop();
if (service.dependencies) {
for (const depName of service.dependencies) {
if (depName in mocks && !serviceRegistry.contains(depName)) {
const dep = mocks[depName]();
serviceRegistry.add(depName, dep);
servicesToProcess.push(dep);
}
}
}
}
if (config.serverData || config.mockRPC || config.activateMockServer) {
await makeMockServer(config.serverData, config.mockRPC);
}
let env = makeEnv();
await startServices(env);
Component.env = env;
if ("config" in config) {
env = Object.assign(Object.create(env), { config: config.config });
}
return env;
}
/**
* Create a test environment for dialog tests
*
* @param {*} config
* @returns {Promise<OdooEnv>}
*/
export async function makeDialogTestEnv(config = {}) {
const env = await makeTestEnv(config);
env.dialogData = {
isActive: true,
close() {},
};
return env;
}

View file

@ -0,0 +1,331 @@
/** @odoo-module alias=@web/../tests/helpers/mock_services default=false */
import { effectService } from "@web/core/effects/effect_service";
import { localization } from "@web/core/l10n/localization";
import { ConnectionAbortedError, rpcBus, rpc } from "@web/core/network/rpc";
import { ormService } from "@web/core/orm_service";
import { overlayService } from "@web/core/overlay/overlay_service";
import { uiService } from "@web/core/ui/ui_service";
import { user } from "@web/core/user";
import { patchWithCleanup } from "./utils";
// -----------------------------------------------------------------------------
// Mock Services
// -----------------------------------------------------------------------------
export const defaultLocalization = {
dateFormat: "MM/dd/yyyy",
timeFormat: "HH:mm:ss",
shortTimeFormat: "HH:mm",
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
decimalPoint: ".",
direction: "ltr",
grouping: [],
multiLang: false,
thousandsSep: ",",
weekStart: 7,
};
/**
* @param {Partial<typeof defaultLocalization>} [config]
*/
export function makeFakeLocalizationService(config = {}) {
patchWithCleanup(localization, { ...defaultLocalization, ...config });
patchWithCleanup(luxon.Settings, { defaultNumberingSystem: "latn" });
return {
name: "localization",
start: async (env) => {
return localization;
},
};
}
export function patchRPCWithCleanup(mockRPC = () => {}) {
let nextId = 1;
patchWithCleanup(rpc, {
_rpc: function (route, params = {}, settings = {}) {
let rejectFn;
const data = {
id: nextId++,
jsonrpc: "2.0",
method: "call",
params: params,
};
rpcBus.trigger("RPC:REQUEST", { data, url: route, settings });
const rpcProm = new Promise((resolve, reject) => {
rejectFn = reject;
Promise.resolve(mockRPC(...arguments))
.then((result) => {
rpcBus.trigger("RPC:RESPONSE", { data, settings, result });
resolve(result);
})
.catch((error) => {
rpcBus.trigger("RPC:RESPONSE", {
data,
settings,
error,
});
reject(error);
});
});
rpcProm.abort = (rejectError = true) => {
if (rejectError) {
rejectFn(new ConnectionAbortedError("XmlHttpRequestError abort"));
}
};
return rpcProm;
},
});
}
export function makeMockXHR(response, sendCb, def) {
const MockXHR = function () {
return {
_loadListener: null,
url: "",
addEventListener(type, listener) {
if (type === "load") {
this._loadListener = listener;
} else if (type === "error") {
this._errorListener = listener;
}
},
set onload(listener) {
this._loadListener = listener;
},
set onerror(listener) {
this._errorListener = listener;
},
open(method, url) {
this.url = url;
},
getResponseHeader() {},
setRequestHeader() {},
async send(data) {
let listener = this._loadListener;
if (sendCb) {
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch {
// Ignore
}
}
try {
await sendCb.call(this, data);
} catch {
listener = this._errorListener;
}
}
if (def) {
await def;
}
listener.call(this);
},
response: JSON.stringify(response || ""),
};
};
return MockXHR;
}
// -----------------------------------------------------------------------------
// Low level API mocking
// -----------------------------------------------------------------------------
export function makeMockFetch(mockRPC) {
return async (input, params) => {
let route = typeof input === "string" ? input : input.url;
if (route.includes("load_menus")) {
const routeArray = route.split("/");
params = {
hash: routeArray.pop(),
};
route = routeArray.join("/");
}
let res;
let status;
try {
res = await mockRPC(route, params);
status = 200;
} catch {
status = 500;
}
const blob = new Blob([JSON.stringify(res || {})], { type: "application/json" });
const response = new Response(blob, { status });
// Mock some functions of the Response API to make them almost synchronous (micro-tick level)
// as their native implementation is async (tick level), which can lead to undeterministic
// errors as it breaks the hypothesis that calling nextTick after fetching data is enough
// to see the result rendered in the DOM.
response.json = () => Promise.resolve(JSON.parse(JSON.stringify(res || {})));
response.text = () => Promise.resolve(String(res || {}));
response.blob = () => Promise.resolve(blob);
return response;
};
}
export const fakeCommandService = {
start() {
return {
add() {
return () => {};
},
getCommands() {
return [];
},
openPalette() {},
};
},
};
export const fakeTitleService = {
start() {
let current = {};
return {
get current() {
return JSON.stringify(current);
},
getParts() {
return current;
},
setParts(parts) {
current = Object.assign({}, current, parts);
},
};
},
};
export const fakeColorSchemeService = {
start() {
return {
switchToColorScheme() {},
};
},
};
export function makeFakeNotificationService(mock) {
return {
start() {
function add() {
if (mock) {
return mock(...arguments);
}
}
return {
add,
};
},
};
}
export function makeFakeDialogService(addDialog, closeAllDialog) {
return {
start() {
return {
add: addDialog || (() => () => {}),
closeAll: closeAllDialog || (() => () => {}),
};
},
};
}
export function makeFakePwaService() {
return {
start() {
return {
canPromptToInstall: false,
isAvailable: false,
isScopedApp: false
}
}
}
}
export function patchUserContextWithCleanup(patch) {
const context = user.context;
patchWithCleanup(user, {
get context() {
return Object.assign({}, context, patch);
},
});
}
export function patchUserWithCleanup(patch) {
patchWithCleanup(user, patch);
}
export const fakeCompanyService = {
start() {
return {
allowedCompanies: {},
allowedCompaniesWithAncestors: {},
activeCompanyIds: [],
currentCompany: {},
setCompanies: () => {},
getCompany: () => {},
};
},
};
export function makeFakeBarcodeService() {
return {
start() {
return {
bus: {
async addEventListener() {},
async removeEventListener() {},
},
};
},
};
}
export function makeFakeHTTPService(getResponse, postResponse) {
getResponse =
getResponse ||
((route, readMethod) => {
return readMethod === "json" ? {} : "";
});
postResponse =
postResponse ||
((route, params, readMethod) => {
return readMethod === "json" ? {} : "";
});
return {
start() {
return {
async get(...args) {
return getResponse(...args);
},
async post(...args) {
return postResponse(...args);
},
};
},
};
}
function makeFakeActionService() {
return {
start() {
return {
doAction() {},
};
},
};
}
export const mocks = {
color_scheme: () => fakeColorSchemeService,
company: () => fakeCompanyService,
command: () => fakeCommandService,
effect: () => effectService, // BOI The real service ? Is this what we want ?
localization: makeFakeLocalizationService,
notification: makeFakeNotificationService,
title: () => fakeTitleService,
ui: () => uiService,
dialog: makeFakeDialogService,
orm: () => ormService,
action: makeFakeActionService,
overlay: () => overlayService,
};

View file

@ -0,0 +1,107 @@
/** @odoo-module alias=@web/../tests/helpers/mount_in_fixture default=false**/
import { App, Component, xml } from "@odoo/owl";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { mocks } from "@web/../tests/helpers/mock_services";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { registry } from "@web/core/registry";
import { getTemplate } from "@web/core/templates";
class TestComponent extends Component {
static props = {
components: { type: Array },
};
static template = xml`
<t t-foreach="props.components" t-as="comp" t-key="comp.component.name">
<t t-component="comp.component" t-props="comp.props"/>
</t>
`;
/**
* Returns the instance of the first component.
* @returns {Component}
*/
get defaultComponent() {
return this.__owl__.bdom.children[0].child.component;
}
}
function getApp(env, props) {
const appConfig = {
env,
getTemplate,
test: true,
props: props,
};
if (env.services && "localization" in env.services) {
appConfig.translateFn = env._t;
}
const app = new App(TestComponent, appConfig);
registerCleanup(() => app.destroy());
return app;
}
/**
* @typedef {Object} Config
* @property {Object} env
* @property {Object} props
* @property {string[]} templates
*/
/**
* This functions will mount the given component and
* will add a MainComponentsContainer if the overlay
* service is loaded.
*
* @template T
* @param {new (...args: any[]) => T} Comp
* @param {HTMLElement} target
* @param {Config} config
* @returns {Promise<T>} Instance of Comp
*/
export async function mountInFixture(Comp, target, config = {}) {
const serviceRegistry = registry.category("services");
let env = config.env || {};
const isEnvInitialized = env && env.services;
function isServiceRegistered(serviceName) {
return isEnvInitialized
? serviceName in env.services
: serviceRegistry.contains(serviceName);
}
async function addService(serviceName, service) {
if (isServiceRegistered(serviceName)) {
return;
}
service = typeof service === "function" ? service() : service;
if (isEnvInitialized) {
env.services[serviceName] = await service.start(env);
} else {
serviceRegistry.add(serviceName, service);
}
}
const components = [{ component: Comp, props: config.props || {} }];
if (isServiceRegistered("overlay")) {
await addService("localization", mocks.localization);
components.push({ component: MainComponentsContainer, props: {} });
}
if (!isEnvInitialized) {
env = await makeTestEnv(env);
}
const app = getApp(env, { components });
if (config.templates) {
app.addTemplates(config.templates);
}
const testComp = await app.mount(target);
return testComp.defaultComponent;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
// @odoo-module ignore
// This module has for sole purpose to mark all odoo modules defined between it
// and ignore_missing_deps_stop as ignored for missing dependency errors.
// see the template conditional_assets_tests to understand how it's used.
window.__odooIgnoreMissingDependencies = true;

View file

@ -0,0 +1,3 @@
// @odoo-module ignore
window.__odooIgnoreMissingDependencies = false;

View file

@ -0,0 +1,165 @@
/** @odoo-module alias=@web/../tests/legacy_tests/core/class_tests default=false */
import Class from "@web/legacy/js/core/class";
QUnit.module('core', {}, function () {
QUnit.module('Class');
QUnit.test('Basic class creation', function (assert) {
assert.expect(2);
var C = Class.extend({
foo: function () {
return this.somevar;
}
});
var i = new C();
i.somevar = 3;
assert.ok(i instanceof C);
assert.strictEqual(i.foo(), 3);
});
QUnit.test('Class initialization', function (assert) {
assert.expect(2);
var C1 = Class.extend({
init: function () {
this.foo = 3;
}
});
var C2 = Class.extend({
init: function (arg) {
this.foo = arg;
}
});
var i1 = new C1(),
i2 = new C2(42);
assert.strictEqual(i1.foo, 3);
assert.strictEqual(i2.foo, 42);
});
QUnit.test('Inheritance', function (assert) {
assert.expect(3);
var C0 = Class.extend({
foo: function () {
return 1;
}
});
var C1 = C0.extend({
foo: function () {
return 1 + this._super();
}
});
var C2 = C1.extend({
foo: function () {
return 1 + this._super();
}
});
assert.strictEqual(new C0().foo(), 1);
assert.strictEqual(new C1().foo(), 2);
assert.strictEqual(new C2().foo(), 3);
});
QUnit.test('In-place extension', function (assert) {
assert.expect(4);
var C0 = Class.extend({
foo: function () {
return 3;
},
qux: function () {
return 3;
},
bar: 3
});
C0.include({
foo: function () {
return 5;
},
qux: function () {
return 2 + this._super();
},
bar: 5,
baz: 5
});
assert.strictEqual(new C0().bar, 5);
assert.strictEqual(new C0().baz, 5);
assert.strictEqual(new C0().foo(), 5);
assert.strictEqual(new C0().qux(), 5);
});
QUnit.test('In-place extension and inheritance', function (assert) {
assert.expect(4);
var C0 = Class.extend({
foo: function () { return 1; },
bar: function () { return 1; }
});
var C1 = C0.extend({
foo: function () { return 1 + this._super(); }
});
assert.strictEqual(new C1().foo(), 2);
assert.strictEqual(new C1().bar(), 1);
C1.include({
foo: function () { return 2 + this._super(); },
bar: function () { return 1 + this._super(); }
});
assert.strictEqual(new C1().foo(), 4);
assert.strictEqual(new C1().bar(), 2);
});
QUnit.test('In-place extensions alter existing instances', function (assert) {
assert.expect(4);
var C0 = Class.extend({
foo: function () { return 1; },
bar: function () { return 1; }
});
var i = new C0();
assert.strictEqual(i.foo(), 1);
assert.strictEqual(i.bar(), 1);
C0.include({
foo: function () { return 2; },
bar: function () { return 2 + this._super(); }
});
assert.strictEqual(i.foo(), 2);
assert.strictEqual(i.bar(), 3);
});
QUnit.test('In-place extension of subclassed types', function (assert) {
assert.expect(3);
var C0 = Class.extend({
foo: function () { return 1; },
bar: function () { return 1; }
});
var C1 = C0.extend({
foo: function () { return 1 + this._super(); },
bar: function () { return 1 + this._super(); }
});
var i = new C1();
assert.strictEqual(i.foo(), 2);
C0.include({
foo: function () { return 2; },
bar: function () { return 2 + this._super(); }
});
assert.strictEqual(i.foo(), 3);
assert.strictEqual(i.bar(), 4);
});
});

View file

@ -0,0 +1,127 @@
/** @odoo-module alias=@web/../tests/legacy_tests/helpers/test_utils default=false */
/**
* Test Utils
*
* In this module, we define various utility functions to help simulate a mock
* environment as close as possible as a real environment.
*/
import testUtilsDom from "@web/../tests/legacy_tests/helpers/test_utils_dom";
import testUtilsFields from "@web/../tests/legacy_tests/helpers/test_utils_fields";
import testUtilsMock from "@web/../tests/legacy_tests/helpers/test_utils_mock";
function deprecated(fn, type) {
return function () {
const msg = `Helper 'testUtils.${fn.name}' is deprecated. ` +
`Please use 'testUtils.${type}.${fn.name}' instead.`;
console.warn(msg);
return fn.apply(this, arguments);
};
}
/**
* 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}
*/
export async function nextTick() {
return testUtilsDom.returnAfterNextAnimationFrame();
}
export const mock = {
intercept: testUtilsMock.intercept,
patch: testUtilsMock.patch,
patchDate: testUtilsMock.patchDate,
unpatch: testUtilsMock.unpatch,
getView: testUtilsMock.getView,
};
export const dom = {
dragAndDrop: testUtilsDom.dragAndDrop,
find: testUtilsDom.findItem,
click: testUtilsDom.click,
clickFirst: testUtilsDom.clickFirst,
triggerEvents: testUtilsDom.triggerEvents,
triggerEvent: testUtilsDom.triggerEvent,
};
export const fields = {
editInput: testUtilsFields.editInput,
editAndTrigger: testUtilsFields.editAndTrigger,
triggerKeydown: testUtilsFields.triggerKeydown,
};
export default {
mock,
dom,
fields,
makeTestPromise: makeTestPromise,
makeTestPromiseWithAssert: makeTestPromiseWithAssert,
nextMicrotaskTick: nextMicrotaskTick,
nextTick: nextTick,
// backward-compatibility
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'),
unpatch: deprecated(testUtilsMock.unpatch, 'mock'),
};

View file

@ -0,0 +1,391 @@
/** @odoo-module alias=@web/../tests/legacy_tests/helpers/test_utils_dom default=false */
import { delay } from "@web/core/utils/concurrency";
/**
* 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});
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}
*/
export 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 }));
}
/**
* 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();
}
/**
* 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 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}
*/
export 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]);
}
}
export default {
click,
clickFirst,
dragAndDrop,
returnAfterNextAnimationFrame,
triggerEvent,
triggerEvents,
};

View file

@ -0,0 +1,88 @@
/** @odoo-module alias=@web/../tests/legacy_tests/helpers/test_utils_fields default=false */
/**
* 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.
*/
import testUtilsDom from "./test_utils_dom";
//-------------------------------------------------------------------------
// 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|EventTarget} el should target an input, textarea or select
* @param {string|number} value
* @param {string[]} events
* @returns {Promise}
*/
export 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}
*/
export async function editInput(el, value) {
return editAndTrigger(el, value, ['input']);
}
/**
* Helper to trigger a key event on an element.
*
* @param {string} type type of key event ('press', 'up' or 'down')
* @param {jQuery} $el
* @param {string} key
* @returns {Promise}
*/
function triggerKey(type, $el, key) {
type = 'key' + type;
const params = {};
params.key = key;
return testUtilsDom.triggerEvent($el, type, params);
}
/**
* Helper to trigger a keydown event on an element.
*
* @param {jQuery} $el
* @param {number|string} key @see triggerKey
* @returns {Promise}
*/
function triggerKeydown($el, key) {
return triggerKey('down', $el, key);
}
export default {
editAndTrigger,
editInput,
triggerKeydown,
};

View file

@ -0,0 +1,74 @@
/** @odoo-module alias=@web/../tests/legacy_tests/helpers/test_utils_mock default=false */
/**
* 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.
*/
import { patchDate } from "@web/../tests/helpers/utils";
//------------------------------------------------------------------------------
// Public functions
//------------------------------------------------------------------------------
/**
* 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:
*
* ```
* 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
}
export default {
intercept: intercept,
patchDate: legacyPatchDate,
};

View file

@ -0,0 +1,34 @@
/** @odoo-module alias=@web/../tests/legacy_tests/helpers/test_utils_tests default=false */
import testUtils from "./test_utils";
const { DateTime } = luxon;
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);
assert.strictEqual(DateTime.now().toFormat("yyyy-MM-dd HH:mm"), '2018-10-23 14:50');
unpatchDate();
});
});
});

View file

@ -0,0 +1,16 @@
/** @odoo-module alias=@web/../tests/legacy_tests/patch_localization default=false */
import { localization } from "@web/core/l10n/localization";
Object.assign(localization, {
dateFormat: "MM/dd/yyyy",
timeFormat: "HH:mm:ss",
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
decimalPoint: ".",
direction: "ltr",
grouping: [],
multiLang: false,
thousandsSep: ",",
weekStart: 7,
code: "en",
});

View file

@ -0,0 +1,10 @@
/** @odoo-module alias=@web/../tests/main default=false */
import { setupQUnit } from "./qunit";
import { setupTests } from "./setup";
(async () => {
setupQUnit();
await setupTests();
QUnit.start();
})();

View file

@ -0,0 +1,84 @@
/** @odoo-module alias=@web/../tests/mobile/helpers default=false */
import { findElement, triggerEvent } from "../helpers/utils";
async function swipe(target, selector, direction) {
const touchTarget = findElement(target, selector);
if (direction === "left") {
// The scrollable element is set at its right limit
touchTarget.scrollLeft = touchTarget.scrollWidth - touchTarget.offsetWidth;
} else {
// The scrollable element is set at its left limit
touchTarget.scrollLeft = 0;
}
await triggerEvent(target, selector, "touchstart", {
touches: [
{
identifier: 0,
clientX: 0,
clientY: 0,
target: touchTarget,
},
],
});
await triggerEvent(target, selector, "touchmove", {
touches: [
{
identifier: 0,
clientX: (direction === "left" ? -1 : 1) * touchTarget.clientWidth,
clientY: 0,
target: touchTarget,
},
],
});
await triggerEvent(target, selector, "touchend", {});
}
/**
* Will simulate a swipe right on the target element with the given selector.
*
* @param {HTMLElement} target
* @param {DOMSelector} [selector]
* @returns {Promise}
*/
export async function swipeRight(target, selector) {
return swipe(target, selector, "right");
}
/**
* Will simulate a swipe left on the target element with the given selector.
*
* @param {HTMLElement} target
* @param {DOMSelector} [selector]
* @returns {Promise}
*/
export async function swipeLeft(target, selector) {
return swipe(target, selector, "left");
}
/**
* Simulate a "TAP" (touch) on the target element with the given selector.
*
* @param {HTMLElement} target
* @param {DOMSelector} [selector]
* @returns {Promise}
*/
export async function tap(target, selector) {
const touchTarget = findElement(target, selector);
const box = touchTarget.getBoundingClientRect();
const x = box.left + box.width / 2;
const y = box.top + box.height / 2;
const touch = {
identifier: 0,
target: touchTarget,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
};
await triggerEvent(touchTarget, null, "touchstart", {
touches: [touch],
});
await triggerEvent(touchTarget, null, "touchend", {});
}

View file

@ -0,0 +1,62 @@
/** @odoo-module alias=@web/../tests/mobile/views/fields/many2many_tags_field_tests default=false */
import { getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char" },
timmy: { string: "pokemon", type: "many2many", relation: "partner_type" },
},
},
partner_type: {
fields: {
name: { string: "Partner Type", type: "char" },
},
records: [
{ id: 12, display_name: "gold" },
{ id: 14, display_name: "silver" },
],
},
},
};
setupViewRegistries();
});
QUnit.module("Many2ManyTagsField");
QUnit.test("Many2ManyTagsField placeholder should be correct", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="timmy" widget="many2many_tags" placeholder="foo"/>
</form>`,
});
assert.strictEqual(target.querySelector("#timmy_0").placeholder, "foo");
});
QUnit.test("Many2ManyTagsField placeholder should be empty", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="timmy" widget="many2many_tags"/>
</form>`,
});
assert.strictEqual(target.querySelector("#timmy_0").placeholder, "");
});
});

View file

@ -0,0 +1,190 @@
/** @odoo-module alias=@web/../tests/mobile/views/fields/many2one_barcode_field_tests default=false */
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { browser } from "@web/core/browser/browser";
import { click, clickSave, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import * as BarcodeScanner from "@web/core/barcode/barcode_dialog";
let serverData;
let target;
const NAME_SEARCH = "name_search";
const PRODUCT_PRODUCT = "product.product";
const SALE_ORDER_LINE = "sale_order_line";
const PRODUCT_FIELD_NAME = "product_id";
// MockRPC to allow the search in barcode too
async function barcodeMockRPC(route, args, performRPC) {
if (args.method === NAME_SEARCH && args.model === PRODUCT_PRODUCT) {
const result = await performRPC(route, args);
const records = serverData.models[PRODUCT_PRODUCT].records
.filter((record) => record.barcode === args.kwargs.name)
.map((record) => [record.id, record.name]);
return records.concat(result);
}
}
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
[PRODUCT_PRODUCT]: {
fields: {
id: { type: "integer" },
name: {},
barcode: {},
},
records: [
{
id: 111,
name: "product_cable_management_box",
barcode: "601647855631",
},
{
id: 112,
name: "product_n95_mask",
barcode: "601647855632",
},
{
id: 113,
name: "product_surgical_mask",
barcode: "601647855633",
},
],
},
[SALE_ORDER_LINE]: {
fields: {
id: { type: "integer" },
[PRODUCT_FIELD_NAME]: {
string: PRODUCT_FIELD_NAME,
type: "many2one",
relation: PRODUCT_PRODUCT,
},
},
},
},
views: {
"product.product,false,kanban": `
<kanban><templates><t t-name="card">
<field name="id"/>
<field name="name"/>
<field name="barcode"/>
</t></templates></kanban>
`,
"product.product,false,search": "<search></search>",
},
};
setupViewRegistries();
patchWithCleanup(AutoComplete, {
delay: 0,
});
// simulate a environment with a camera/webcam
patchWithCleanup(
browser,
Object.assign({}, browser, {
setTimeout: (fn) => fn(),
navigator: {
userAgent: "Chrome/0.0.0 (Linux; Android 13; Odoo TestSuite)",
mediaDevices: {
getUserMedia: () => [],
},
},
})
);
});
QUnit.module("Many2OneField Barcode (Small)");
QUnit.test("barcode button with multiple results", async function (assert) {
assert.expect(4);
// The product selected (mock) for the barcode scanner
const selectedRecordTest = serverData.models[PRODUCT_PRODUCT].records[1];
patchWithCleanup(BarcodeScanner, {
scanBarcode: async () => "mask",
});
await makeView({
type: "form",
resModel: SALE_ORDER_LINE,
serverData,
arch: `
<form>
<field name="${PRODUCT_FIELD_NAME}" options="{'can_scan_barcode': True}"/>
</form>`,
async mockRPC(route, args, performRPC) {
if (args.method === "web_save" && args.model === SALE_ORDER_LINE) {
const selectedId = args.args[1][PRODUCT_FIELD_NAME];
assert.equal(
selectedId,
selectedRecordTest.id,
`product id selected ${selectedId}, should be ${selectedRecordTest.id} (${selectedRecordTest.barcode})`
);
return performRPC(route, args, performRPC);
}
return barcodeMockRPC(route, args, performRPC);
},
});
const scanButton = target.querySelector(".o_barcode");
assert.containsOnce(target, scanButton, "has scanner barcode button");
await click(target, ".o_barcode");
const modal = target.querySelector(".modal-dialog.modal-lg");
assert.containsOnce(target, modal, "there should be one modal opened in full screen");
assert.containsN(
modal,
".o_kanban_record:not(.o_kanban_ghost)",
2,
"there should be 2 records displayed"
);
await click(modal, ".o_kanban_record:nth-child(1)");
await clickSave(target);
});
QUnit.test("many2one with barcode show all records", async function (assert) {
// The product selected (mock) for the barcode scanner
const selectedRecordTest = serverData.models[PRODUCT_PRODUCT].records[0];
patchWithCleanup(BarcodeScanner, {
scanBarcode: async () => selectedRecordTest.barcode,
});
await makeView({
type: "form",
resModel: SALE_ORDER_LINE,
serverData,
arch: `
<form>
<field name="${PRODUCT_FIELD_NAME}" options="{'can_scan_barcode': True}"/>
</form>`,
mockRPC: barcodeMockRPC,
});
// Select one product
await click(target, ".o_barcode");
// Click on the input to show all records
await click(target, ".o_input_dropdown > input");
const modal = target.querySelector(".modal-dialog.modal-lg");
assert.containsOnce(target, modal, "there should be one modal opened in full screen");
assert.containsN(
modal,
".o_kanban_record:not(.o_kanban_ghost)",
3,
"there should be 3 records displayed"
);
});
});

View file

@ -0,0 +1,127 @@
/** @odoo-module alias=@web/../tests/mobile/views/fields/statusbar_field_tests default=false */
import { click, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
let fixture;
let serverData;
QUnit.module("Mobile Fields", ({ beforeEach }) => {
beforeEach(() => {
setupViewRegistries();
fixture = getFixture();
fixture.setAttribute("style", "width:100vw; height:100vh;");
registerCleanup(() => fixture.removeAttribute("style"));
serverData = {
models: {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char" },
trululu: { string: "Trululu", type: "many2one", relation: "partner" },
},
records: [
{ id: 1, display_name: "first record", trululu: 4 },
{ id: 2, display_name: "second record", trululu: 1 },
{ id: 3, display_name: "third record" },
{ id: 4, display_name: "aaa" },
],
},
},
};
});
QUnit.module("StatusBarField");
QUnit.test("statusbar is rendered correctly on small devices", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<header>
<field name="trululu" widget="statusbar" />
</header>
<field name="display_name" />
</form>
`,
});
assert.containsN(fixture, ".o_statusbar_status .o_arrow_button:visible", 4);
assert.containsOnce(fixture, ".o_statusbar_status .o_arrow_button.dropdown-toggle:visible");
assert.containsOnce(fixture, ".o_statusbar_status .o_arrow_button.o_arrow_button_current");
assert.containsNone(fixture, ".o-dropdown--menu", "dropdown should be hidden");
assert.strictEqual(
fixture.querySelector(".o_statusbar_status button.dropdown-toggle").textContent.trim(),
"..."
);
// open the dropdown
await click(fixture, ".o_statusbar_status .dropdown-toggle.o_last");
assert.containsOnce(fixture, ".o-dropdown--menu", "dropdown should be visible");
assert.containsOnce(fixture, ".o-dropdown--menu .dropdown-item.disabled");
});
QUnit.test("statusbar with no status on extra small screens", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resId: 4,
serverData,
arch: `
<form>
<header>
<field name="trululu" widget="statusbar" />
</header>
</form>
`,
});
assert.doesNotHaveClass(
fixture.querySelector(".o_field_statusbar"),
"o_field_empty",
"statusbar widget should have class o_field_empty in edit"
);
assert.containsOnce(fixture, ".o_statusbar_status button.dropdown-toggle:visible:disabled");
assert.strictEqual(
$(".o_statusbar_status button.dropdown-toggle:visible:disabled").text().trim(),
"..."
);
});
QUnit.test("clickable statusbar widget on mobile view", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<header>
<field name="trululu" widget="statusbar" options="{'clickable': '1'}" />
</header>
</form>
`,
});
// Open dropdown
await click($(".o_statusbar_status .dropdown-toggle:visible")[0]);
assert.containsOnce(fixture, ".o-dropdown--menu .dropdown-item");
await click(fixture, ".o-dropdown--menu .dropdown-item");
assert.strictEqual($(".o_arrow_button_current").text(), "first record");
assert.containsN(fixture, ".o_statusbar_status .o_arrow_button:visible", 3);
assert.containsOnce(fixture, ".o_statusbar_status .dropdown-toggle:visible");
// Open second dropdown
await click($(".o_statusbar_status .dropdown-toggle:visible")[0]);
assert.containsN(fixture, ".o-dropdown--menu .dropdown-item", 2);
});
});

View file

@ -0,0 +1,360 @@
/** @odoo-module alias=@web/../tests/mobile/views/form_view_tests default=false */
import { registry } from "@web/core/registry";
import {
click,
editInput,
getFixture,
makeDeferred,
nextTick,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { AttachDocumentWidget } from "@web/views/widgets/attach_document/attach_document";
let fixture;
let serverData;
const serviceRegistry = registry.category("services");
QUnit.module("Mobile Views", ({ beforeEach }) => {
beforeEach(() => {
setupViewRegistries();
fixture = getFixture();
serverData = {
models: {
partner: {
fields: {
display_name: { type: "char", string: "Display Name" },
trululu: { type: "many2one", string: "Trululu", relation: "partner" },
boolean: { type: "boolean", string: "Bool" },
},
records: [
{ id: 1, display_name: "first record", trululu: 4 },
{ id: 2, display_name: "second record", trululu: 1 },
{ id: 4, display_name: "aaa" },
],
},
},
};
});
QUnit.module("FormView");
QUnit.test(`statusbar buttons are correctly rendered in mobile`, async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<header>
<button string="Confirm" />
<button string="Do it" />
</header>
<sheet>
<group>
<button name="display_name" />
</group>
</sheet>
</form>
`,
});
// open the dropdown
await click(fixture, ".o_cp_action_menus button:has(.fa-cog)");
assert.containsOnce(fixture, ".o-dropdown--menu:visible", "dropdown should be visible");
assert.containsN(
fixture,
".o-dropdown--menu button",
2,
"dropdown should contain 2 buttons"
);
});
QUnit.test(`statusbar widgets should appear in the CogMenu dropdown`, async (assert) => {
serviceRegistry.add("http", {
start: () => ({}),
});
await makeView({
type: "form",
resModel: "partner",
serverData,
resId: 2,
arch: `
<form>
<header>
<widget name="attach_document" string="Attach document" />
<button string="Ciao" invisible="display_name == 'first record'" />
</header>
<sheet>
<group>
<field name="display_name" />
</group>
</sheet>
</form>
`,
});
// Now there should an action dropdown, because there are two visible buttons
assert.containsOnce(
fixture,
".o_cp_action_menus button:has(.fa-cog)",
"should have 'CogMenu' dropdown"
);
await click(fixture, ".o_cp_action_menus button:has(.fa-cog)");
assert.containsN(
fixture,
".o-dropdown--menu button",
2,
"should have 2 buttons in the dropdown"
);
// change display_name to update buttons modifiers and make one button visible
await editInput(fixture, ".o_field_widget[name=display_name] input", "first record");
assert.containsOnce(
fixture,
".o-dropdown--menu button",
"should have 1 button in the dropdown"
);
});
QUnit.test(`CogMenu dropdown should keep its open/close state`, async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<header>
<button string="Just more than one" />
<button string="Confirm" invisible="display_name == ''" />
<button string="Do it" invisible="display_name != ''" />
</header>
<sheet>
<field name="display_name" />
</sheet>
</form>
`,
});
assert.containsOnce(
fixture,
".o_cp_action_menus button:has(.fa-cog)",
"should have a 'CogMenu' dropdown"
);
assert.doesNotHaveClass(
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
"show",
"dropdown should be closed"
);
// open the dropdown
await click(fixture, ".o_cp_action_menus button:has(.fa-cog)");
assert.hasClass(
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
"show",
"dropdown should be opened"
);
// change display_name to update buttons' modifiers
await editInput(fixture, ".o_field_widget[name=display_name] input", "test");
assert.containsOnce(
fixture,
".o_cp_action_menus button:has(.fa-cog)",
"should have a 'CogMenu' dropdown"
);
assert.hasClass(
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
"show",
"dropdown should still be opened"
);
});
QUnit.test(
`CogMenu dropdown's open/close state shouldn't be modified after 'onchange'`,
async function (assert) {
serverData.models.partner.onchanges = {
display_name: async () => {},
};
const onchangeDef = makeDeferred();
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<header>
<button name="create" string="Create Invoice" type="action" />
<button name="send" string="Send by Email" type="action" />
</header>
<sheet>
<field name="display_name" />
</sheet>
</form>
`,
mockRPC(route, { method, args }) {
if (method === "onchange" && args[2][0] === "display_name") {
return onchangeDef;
}
},
});
assert.containsOnce(
fixture,
".o_cp_action_menus button:has(.fa-cog)",
"statusbar should contain a dropdown"
);
assert.doesNotHaveClass(
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
"show",
"dropdown should be closed"
);
await editInput(fixture, ".o_field_widget[name=display_name] input", "before onchange");
await click(fixture, ".o_cp_action_menus button:has(.fa-cog)");
assert.hasClass(
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
"show",
"dropdown should be opened"
);
onchangeDef.resolve({ value: { display_name: "after onchange" } });
await nextTick();
assert.strictEqual(
fixture.querySelector(".o_field_widget[name=display_name] input").value,
"after onchange"
);
assert.hasClass(
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
"show",
"dropdown should still be opened"
);
}
);
QUnit.test(
`preserve current scroll position on form view while closing dialog`,
async function (assert) {
serverData.views = {
"partner,false,kanban": `
<kanban>
<templates>
<t t-name="card">
<field name="display_name" />
</t>
</templates>
</kanban>
`,
"partner,false,search": `
<search />
`,
};
await makeView({
type: "form",
resModel: "partner",
resId: 2,
serverData,
arch: `
<form>
<sheet>
<p style="height:500px" />
<field name="trululu" />
<p style="height:500px" />
</sheet>
</form>
`,
});
let position = { top: 0, left: 0 };
patchWithCleanup(window, {
scrollTo(newPosition) {
position = newPosition;
},
get scrollX() {
return position.left;
},
get scrollY() {
return position.top;
},
});
window.scrollTo({ top: 265, left: 0 });
assert.strictEqual(window.scrollY, 265, "Should have scrolled 265 px vertically");
assert.strictEqual(window.scrollX, 0, "Should be 0 px from left as it is");
// click on m2o field
await click(fixture, ".o_field_many2one input");
// assert.strictEqual(window.scrollY, 0, "Should have scrolled to top (0) px");
assert.containsOnce(
fixture,
".modal.o_modal_full",
"there should be a many2one modal opened in full screen"
);
// click on back button
await click(fixture, ".modal .modal-header .oi-arrow-left");
assert.strictEqual(
window.scrollY,
265,
"Should have scrolled back to 265 px vertically"
);
assert.strictEqual(window.scrollX, 0, "Should be 0 px from left as it is");
}
);
QUnit.test("attach_document widget also works inside a dropdown", async (assert) => {
let fileInput;
patchWithCleanup(AttachDocumentWidget.prototype, {
setup() {
super.setup();
fileInput = this.fileInput;
},
});
serviceRegistry.add("http", {
start: () => ({
post: (route, params) => {
assert.step("post");
assert.strictEqual(route, "/web/binary/upload_attachment");
assert.strictEqual(params.model, "partner");
assert.strictEqual(params.id, 1);
return '[{ "id": 5 }, { "id": 2 }]';
},
}),
});
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<header>
<button string="Confirm" />
<widget name="attach_document" string="Attach Document"/>
</header>
<sheet>
<group>
<button name="display_name" />
</group>
</sheet>
</form>
`,
});
await click(fixture, ".o_cp_action_menus button:has(.fa-cog)");
await click(fixture, ".o_attach_document");
fileInput.dispatchEvent(new Event("change"));
await nextTick();
assert.verifySteps(["post"]);
});
});

View file

@ -0,0 +1,102 @@
/** @odoo-module alias=@web/../tests/mobile/views/kanban_view_tests default=false */
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { click, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
import { AnimatedNumber } from "@web/views/view_components/animated_number";
let serverData;
let target;
QUnit.module("Views", (hooks) => {
hooks.beforeEach(() => {
patchWithCleanup(AnimatedNumber, { enableAnimations: false });
serverData = {
models: {
partner: {
fields: {
foo: { string: "Foo", type: "char" },
product_id: {
string: "something_id",
type: "many2one",
relation: "product",
},
},
records: [
{
id: 1,
foo: "yop",
product_id: 3,
},
{
id: 2,
foo: "blip",
product_id: 5,
},
{
id: 3,
foo: "gnap",
product_id: 3,
},
{
id: 4,
foo: "blip",
product_id: 5,
},
],
},
product: {
fields: {
id: { string: "ID", type: "integer" },
name: { string: "Display Name", type: "char" },
},
records: [
{ id: 3, name: "hello" },
{ id: 5, name: "xmo" },
],
},
},
views: {},
};
target = getFixture();
setupViewRegistries();
});
QUnit.module("KanbanView");
QUnit.test("Should load grouped kanban with folded column", async (assert) => {
await makeView({
type: "kanban",
resModel: "partner",
serverData,
arch: `
<kanban>
<progressbar field="foo" colors='{"yop": "success", "blip": "danger"}'/>
<templates>
<t t-name="card">
<field name="foo"/>
</t>
</templates>
</kanban>`,
groupBy: ["product_id"],
async mockRPC(route, args, performRPC) {
if (args.method === "web_read_group") {
const result = await performRPC(route, args);
result.groups[1].__fold = true;
return result;
}
},
});
assert.containsN(target, ".o_column_progress", 2, "Should have 2 progress bar");
assert.containsN(target, ".o_kanban_group", 2, "Should have 2 grouped column");
assert.containsN(target, ".o_kanban_record", 2, "Should have 2 loaded record");
assert.containsOnce(
target,
".o_kanban_load_more",
"Should have a folded column with a load more button"
);
await click(target, ".o_kanban_load_more button");
assert.containsNone(target, ".o_kanban_load_more", "Shouldn't have a load more button");
assert.containsN(target, ".o_kanban_record", 4, "Should have 4 loaded record");
});
});

View file

@ -0,0 +1,251 @@
/** @odoo-module alias=@web/../tests/mobile/views/list_view_tests default=false */
import { browser } from "@web/core/browser/browser";
import { click, getFixture, patchWithCleanup, triggerEvents } from "@web/../tests/helpers/utils";
import { getMenuItemTexts, toggleActionMenu } from "@web/../tests/search/helpers";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { patchUserWithCleanup } from "../../helpers/mock_services";
let serverData;
let fixture;
QUnit.module("Mobile Views", ({ beforeEach }) => {
beforeEach(() => {
setupViewRegistries();
fixture = getFixture();
serverData = {
models: {
foo: {
fields: {
foo: { string: "Foo", type: "char" },
bar: { string: "Bar", type: "boolean" },
},
records: [
{ id: 1, bar: true, foo: "yop" },
{ id: 2, bar: true, foo: "blip" },
{ id: 3, bar: true, foo: "gnap" },
{ id: 4, bar: false, foo: "blip" },
],
},
},
};
patchWithCleanup(browser, {
setTimeout: (fn) => fn() || true,
clearTimeout: () => {},
});
});
QUnit.module("ListView");
QUnit.test("selection is properly displayed (single page)", async function (assert) {
patchUserWithCleanup({ hasGroup: () => Promise.resolve(false) });
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list>
<field name="foo"/>
<field name="bar"/>
</list>
`,
loadActionMenus: true,
});
assert.containsN(fixture, ".o_data_row", 4);
assert.containsNone(fixture, ".o_list_selection_box");
assert.containsOnce(fixture, ".o_control_panel .fa-search");
// select a record
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
assert.containsOnce(fixture, ".o_list_selection_box");
assert.containsOnce(fixture, ".o_list_selection_box .o_list_select_domain");
assert.containsNone(fixture, ".o_control_panel .o_cp_searchview");
assert.ok(
fixture.querySelector(".o_list_selection_box").textContent.includes("1 selected")
);
// unselect a record
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
assert.containsNone(fixture, ".o_list_selection_box .o_list_select_domain");
// select 2 records
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
await triggerEvents(fixture, ".o_data_row:nth-child(2)", ["touchstart", "touchend"]);
assert.ok(
fixture.querySelector(".o_list_selection_box > span").textContent.includes("2 selected")
);
assert.ok(
fixture.querySelector(".o_list_selection_box > button.o_list_select_domain").textContent.includes("All")
);
assert.containsOnce(fixture, "div.o_control_panel .o_cp_action_menus");
await toggleActionMenu(fixture);
assert.deepEqual(
getMenuItemTexts(fixture),
["Duplicate", "Delete"],
"action menu should contain the Duplicate and Delete actions"
);
// unselect all
await click(fixture, ".o_list_unselect_all");
assert.containsNone(fixture, ".o_list_selection_box");
assert.containsOnce(fixture, ".o_control_panel .fa-search");
});
QUnit.test("selection box is properly displayed (multi pages)", async function (assert) {
patchUserWithCleanup({ hasGroup: () => Promise.resolve(false) });
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list limit="3">
<field name="foo"/>
<field name="bar"/>
</list>
`,
loadActionMenus: true,
});
assert.containsN(fixture, ".o_data_row", 3);
assert.containsNone(fixture, ".o_list_selection_box");
// select a record
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
assert.containsOnce(fixture, ".o_list_selection_box");
assert.containsOnce(fixture, ".o_list_selection_box .o_list_select_domain");
assert.ok(
fixture.querySelector(".o_list_selection_box").textContent.includes("1 selected")
);
assert.containsOnce(fixture, ".o_list_selection_box");
assert.containsOnce(fixture, "div.o_control_panel .o_cp_action_menus");
await toggleActionMenu(fixture);
assert.deepEqual(
getMenuItemTexts(fixture),
["Duplicate", "Delete"],
"action menu should contain the Duplicate and Delete actions"
);
// select all records of first page
await triggerEvents(fixture, ".o_data_row:nth-child(2)", ["touchstart", "touchend"]);
await triggerEvents(fixture, ".o_data_row:nth-child(3)", ["touchstart", "touchend"]);
assert.containsOnce(fixture, ".o_list_selection_box");
assert.containsOnce(fixture, ".o_list_selection_box .o_list_select_domain");
assert.ok(
fixture.querySelector(".o_list_selection_box").textContent.includes("3 selected")
);
assert.containsOnce(fixture, ".o_list_select_domain");
// select all domain
await click(fixture, ".o_list_selection_box .o_list_select_domain");
assert.containsOnce(fixture, ".o_list_selection_box");
assert.ok(
fixture.querySelector(".o_list_selection_box").textContent.includes("All 4 selected")
);
});
QUnit.test("export button is properly hidden", async (assert) => {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list>
<field name="foo"/>
<field name="bar"/>
</list>
`,
});
assert.containsN(fixture, ".o_data_row", 4);
assert.isNotVisible(fixture.querySelector(".o_list_export_xlsx"));
});
QUnit.test("editable readonly list view is disabled", async (assert) => {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list>
<field name="foo" />
</list>
`,
});
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
await click(fixture, ".o_data_row:nth-child(1) .o_data_cell:nth-child(1)");
assert.containsNone(
fixture,
".o_selected_row .o_field_widget[name=foo]",
"The listview should not contains an edit field"
);
});
QUnit.test("add custom field button not shown in mobile (with opt. col.)", async (assert) => {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list>
<field name="foo" />
<field name="bar" optional="hide" />
</list>
`,
});
assert.containsOnce(fixture, "table .o_optional_columns_dropdown_toggle");
await click(fixture, "table .o_optional_columns_dropdown_toggle");
assert.containsOnce(fixture, ".dropdown-item");
});
QUnit.test(
"add custom field button not shown to non-system users (wo opt. col.)",
async (assert) => {
patchUserWithCleanup({ isSystem: false });
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list>
<field name="foo" />
<field name="bar" />
</list>
`,
});
assert.containsNone(fixture, "table .o_optional_columns_dropdown_toggle");
}
);
QUnit.test("list view header buttons are shift on the cog menus", async (assert) => {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list>
<header>
<button name="x" type="object" class="plaf" string="plaf"/>
</header>
<field name="foo"/>
</list>
`,
});
const getTextMenu = () => [...fixture.querySelectorAll(`.o_popover .o-dropdown-item`)].map((e) => e.innerText.trim());
assert.containsOnce(fixture, ".o_control_panel_breadcrumbs .o_cp_action_menus .fa-cog");
await click(fixture, ".o_control_panel_breadcrumbs .o_cp_action_menus .fa-cog");
assert.deepEqual(getTextMenu(), ["Export All"]);
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
await click(fixture, ".o_control_panel_breadcrumbs .o_cp_action_menus .fa-cog");
assert.deepEqual(getTextMenu(), ["plaf", "Export", "Duplicate", "Delete"]);
});
});

View file

@ -0,0 +1,185 @@
/** @odoo-module alias=@web/../tests/mobile/views/view_dialog/select_create_dialog_tests default=false */
import { click, getFixture, editInput } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
QUnit.module("ViewDialogs", (hooks) => {
let serverData;
let target;
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
product: {
fields: {
id: { type: "integer" },
name: {},
},
records: [
{
id: 111,
name: "product_cable_management_box",
},
],
},
sale_order_line: {
fields: {
id: { type: "integer" },
product_id: {
string: "product_id",
type: "many2one",
relation: "product",
},
linked_sale_order_line: {
string: "linked_sale_order_line",
type: "many2many",
relation: "sale_order_line",
},
},
},
},
views: {
"product,false,kanban": `
<kanban><templates><t t-name="card">
<field name="id"/>
<field name="name"/>
</t></templates></kanban>
`,
"sale_order_line,false,kanban": `
<kanban><templates><t t-name="card">
<field name="id"/>
</t></templates></kanban>
`,
"product,false,search": "<search></search>",
},
};
setupViewRegistries();
});
QUnit.module("SelectCreateDialog - Mobile");
QUnit.test("SelectCreateDialog: clear selection in mobile", async function (assert) {
assert.expect(3);
await makeView({
type: "form",
resModel: "sale_order_line",
serverData,
arch: `
<form>
<field name="product_id"/>
<field name="linked_sale_order_line" widget="many2many_tags"/>
</form>`,
async mockRPC(route, args) {
if (args.method === "web_save" && args.model === "sale_order_line") {
const { product_id: selectedId } = args.args[1];
assert.strictEqual(selectedId, false, `there should be no product selected`);
}
},
});
const clearBtnSelector = ".btn.o_clear_button";
await click(target, '.o_field_widget[name="linked_sale_order_line"] input');
let modal = target.querySelector(".modal-dialog.modal-lg");
assert.containsNone(modal, clearBtnSelector, "there shouldn't be a Clear button");
await click(modal, ".o_form_button_cancel");
// Select a product
await click(target, '.o_field_widget[name="product_id"] input');
modal = target.querySelector(".modal-dialog.modal-lg");
await click(modal, ".o_kanban_record:nth-child(1)");
// Remove the product
await click(target, '.o_field_widget[name="product_id"] input');
modal = target.querySelector(".modal-dialog.modal-lg");
assert.containsOnce(modal, clearBtnSelector, "there should be a Clear button");
await click(modal, clearBtnSelector);
await click(target, ".o_form_button_save");
});
QUnit.test("SelectCreateDialog: selection_mode should be true", async function (assert) {
assert.expect(3);
serverData.views["product,false,kanban"] = `
<kanban>
<templates>
<t t-name="card">
<div class="o_primary" t-if="!selection_mode">
<a type="object" name="some_action">
<field name="name"/>
</a>
</div>
<div class="o_primary" t-if="selection_mode">
<field name="name"/>
</div>
</t>
</templates>
</kanban>`;
await makeView({
type: "form",
resModel: "sale_order_line",
serverData,
arch: `
<form>
<field name="product_id"/>
<field name="linked_sale_order_line" widget="many2many_tags"/>
</form>`,
async mockRPC(route, args) {
if (args.method === "web_save" && args.model === "sale_order_line") {
const { product_id: selectedId } = args.args[1];
assert.strictEqual(selectedId, 111, `the product should be selected`);
}
if (args.method === "some_action") {
assert.step("action should not be called");
}
},
});
await click(target, '.o_field_widget[name="product_id"] input');
await click(target, ".modal-dialog.modal-lg .o_kanban_record:nth-child(1) .o_primary span");
assert.containsNone(target, ".modal-dialog.modal-lg");
await click(target, ".o_form_button_save");
assert.verifySteps([]);
});
QUnit.test("SelectCreateDialog: default props, create a record", async function (assert) {
assert.expect(9);
serverData.views["product,false,form"] = `<form><field name="display_name"/></form>`;
await makeView({
type: "form",
resModel: "sale_order_line",
serverData,
arch: `
<form>
<field name="product_id"/>
<field name="linked_sale_order_line" widget="many2many_tags"/>
</form>`,
});
await click(target, '.o_field_widget[name="product_id"] input');
assert.containsOnce(target, ".o_dialog");
assert.containsOnce(
target,
".o_dialog .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)"
);
assert.containsN(target, ".o_dialog footer button", 2);
assert.containsOnce(target, ".o_dialog footer button.o_create_button");
assert.containsOnce(target, ".o_dialog footer button.o_form_button_cancel");
assert.containsNone(target, ".o_dialog .o_control_panel_main_buttons .o-kanban-button-new");
await click(target.querySelector(".o_dialog footer button.o_create_button"));
assert.containsN(target, ".o_dialog", 2);
assert.containsOnce(target, ".o_dialog .o_form_view");
await editInput(target, ".o_dialog .o_form_view .o_field_widget input", "hello");
await click(target.querySelector(".o_dialog .o_form_button_save"));
assert.containsNone(target, ".o_dialog");
});
});

View file

@ -0,0 +1,115 @@
/** @odoo-module alias=@web/../tests/mobile/views/widgets/signature_tests default=false */
import { click, getFixture, patchWithCleanup, editInput, nextTick } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { SignatureWidget } from "@web/views/widgets/signature/signature";
let serverData;
let target;
QUnit.module("Widgets", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
display_name: { string: "Name", type: "char" },
product_id: {
string: "Product Name",
type: "many2one",
relation: "product",
},
signature: { string: "", type: "string" },
},
records: [
{
id: 1,
display_name: "Pop's Chock'lit",
product_id: 7,
},
],
onchanges: {},
},
product: {
fields: {
name: { string: "Product Name", type: "char" },
},
records: [
{
id: 7,
display_name: "Veggie Burger",
},
],
},
},
};
setupViewRegistries();
});
QUnit.module("Signature Widget");
QUnit.test("Signature widget works inside of a dropdown", async (assert) => {
assert.expect(7);
patchWithCleanup(SignatureWidget.prototype, {
async onClickSignature() {
await super.onClickSignature(...arguments);
assert.step("onClickSignature");
},
async uploadSignature({signatureImage}) {
await super.uploadSignature(...arguments);
assert.step("uploadSignature");
},
});
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<header>
<button string="Dummy"/>
<widget name="signature" string="Sign" full_name="display_name"/>
</header>
<field name="display_name" />
</form>
`,
mockRPC: async (route, args) => {
if (route === "/web/sign/get_fonts/") {
return {};
}
},
});
// change display_name to enable auto-sign feature
await editInput(target, ".o_field_widget[name=display_name] input", "test");
// open the signature dialog
await click(target, ".o_cp_action_menus button:has(.fa-cog)");
await click(target, ".o_widget_signature button.o_sign_button");
assert.containsOnce(target, ".modal-dialog", "Should have one modal opened");
// use auto-sign feature, might take a while
await click(target, ".o_web_sign_auto_button");
assert.containsOnce(target, ".modal-footer button.btn-primary");
let maxDelay = 100;
while (target.querySelector(".modal-footer button.btn-primary")["disabled"] && maxDelay > 0) {
await nextTick();
maxDelay--;
}
assert.equal(maxDelay > 0, true, "Timeout exceeded");
// close the dialog and save the signature
await click(target, ".modal-footer button.btn-primary:enabled");
assert.containsNone(target, ".modal-dialog", "Should have no modal opened");
assert.verifySteps(["onClickSignature", "uploadSignature"], "An error has occurred while signing");
});
});

View file

@ -0,0 +1,154 @@
/** @odoo-module alias=@web/../tests/mobile/webclient/burger_menu/burger_user_menu_tests default=false */
import { ormService } from "@web/core/orm_service";
import { registry } from "@web/core/registry";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { BurgerUserMenu } from "@web/webclient/burger_menu/burger_user_menu/burger_user_menu";
import { preferencesItem } from "@web/webclient/user_menu/user_menu_items";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import { click, getFixture, mount } from "@web/../tests/helpers/utils";
import { markup } from "@odoo/owl";
const serviceRegistry = registry.category("services");
const userMenuRegistry = registry.category("user_menuitems");
let target;
let env;
QUnit.module("BurgerUserMenu", {
async beforeEach() {
serviceRegistry.add("hotkey", hotkeyService);
target = getFixture();
},
});
QUnit.test("can be rendered", async (assert) => {
env = await makeTestEnv();
userMenuRegistry.add("bad_item", function () {
return {
type: "item",
id: "bad",
description: "Bad",
callback: () => {
assert.step("callback bad_item");
},
sequence: 10,
};
});
userMenuRegistry.add("ring_item", function () {
return {
type: "item",
id: "ring",
description: "Ring",
callback: () => {
assert.step("callback ring_item");
},
sequence: 5,
};
});
userMenuRegistry.add("frodo_item", function () {
return {
type: "switch",
id: "frodo",
description: "Frodo",
callback: () => {
assert.step("callback frodo_item");
},
sequence: 11,
};
});
userMenuRegistry.add("separator", function () {
return {
type: "separator",
sequence: 15,
};
});
userMenuRegistry.add("invisible_item", function () {
return {
type: "item",
id: "hidden",
description: "Hidden Power",
callback: () => {
assert.step("callback hidden_item");
},
sequence: 5,
hide: true,
};
});
userMenuRegistry.add("eye_item", function () {
return {
type: "item",
id: "eye",
description: "Eye",
callback: () => {
assert.step("callback eye_item");
},
};
});
userMenuRegistry.add("html_item", function () {
return {
type: "item",
id: "html",
description: markup(`<div>HTML<i class="fa fa-check px-2"></i></div>`),
callback: () => {
assert.step("callback html_item");
},
sequence: 20,
};
});
await mount(BurgerUserMenu, target, { env });
assert.containsN(target, "a", 4);
assert.containsOnce(target, ".form-switch input.form-check-input");
assert.containsOnce(target, "hr");
const items = [...target.querySelectorAll("a, .form-switch")] || [];
assert.deepEqual(
items.map((el) => el.textContent),
["Ring", "Bad", "Frodo", "HTML", "Eye"]
);
for (const item of items) {
click(item);
}
assert.verifySteps([
"callback ring_item",
"callback bad_item",
"callback frodo_item",
"callback html_item",
"callback eye_item",
]);
});
QUnit.test("can execute the callback of settings", async (assert) => {
const mockRPC = (route) => {
if (route === "/web/dataset/call_kw/res.users/action_get") {
return Promise.resolve({
name: "Change My Preferences",
res_id: 0,
});
}
};
const testConfig = { mockRPC };
serviceRegistry.add("localization", makeFakeLocalizationService());
serviceRegistry.add("orm", ormService);
const fakeActionService = {
name: "action",
start() {
return {
doAction(actionId) {
assert.step("" + actionId.res_id);
assert.step(actionId.name);
return Promise.resolve(true);
},
};
},
};
serviceRegistry.add("action", fakeActionService, { force: true });
env = await makeTestEnv(testConfig);
userMenuRegistry.add("profile", preferencesItem);
await mount(BurgerUserMenu, target, { env });
assert.containsOnce(target, "a");
const item = target.querySelector("a");
assert.strictEqual(item.textContent, "Preferences");
await click(item);
assert.verifySteps(["7", "Change My Preferences"]);
});

View file

@ -0,0 +1,140 @@
/** @odoo-module alias=@web/../tests/mobile/webclient/settings_form_view_tests default=false */
import { getFixture, mockTimeout, nextTick } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { swipeLeft, swipeRight } from "@web/../tests/mobile/helpers";
import { registry } from "@web/core/registry";
import { EventBus } from "@odoo/owl";
let serverData, target;
const serviceRegistry = registry.category("services");
QUnit.module("Mobile SettingsFormView", (hooks) => {
hooks.beforeEach(() => {
serverData = {
models: {
project: {
fields: {
foo: { string: "Foo", type: "boolean" },
bar: { string: "Bar", type: "boolean" },
},
},
},
};
target = getFixture();
setupViewRegistries();
});
QUnit.module("BaseSettings Mobile");
QUnit.test("swipe settings in mobile [REQUIRE TOUCHEVENT]", async function (assert) {
const { execRegisteredTimeouts } = mockTimeout();
serviceRegistry.add("ui", {
start(env) {
Object.defineProperty(env, "isSmall", {
value: true,
});
return {
bus: new EventBus(),
size: 0,
isSmall: true,
};
},
});
await makeView({
type: "form",
resModel: "project",
serverData,
arch: `
<form string="Settings" class="oe_form_configuration o_base_settings" js_class="base_settings">
<app string="CRM" name="crm">
<block>
<setting help="this is bar">
<field name="bar"/>
</setting>
</block>
</app>
<app string="Project" name="project">
<block>
<setting help="this is foo">
<field name="foo"/>
</setting>
</block>
</app>
</form>`,
});
await swipeLeft(target, ".settings");
execRegisteredTimeouts();
await nextTick();
assert.hasAttrValue(
target.querySelector(".selected"),
"data-key",
"project",
"current setting should be project"
);
await swipeRight(target, ".settings");
execRegisteredTimeouts();
await nextTick();
assert.hasAttrValue(
target.querySelector(".selected"),
"data-key",
"crm",
"current setting should be crm"
);
});
QUnit.test(
"swipe settings on larger screen sizes has no effect [REQUIRE TOUCHEVENT]",
async function (assert) {
const { execRegisteredTimeouts } = mockTimeout();
serviceRegistry.add("ui", {
start(env) {
Object.defineProperty(env, "isSmall", {
value: false,
});
return {
bus: new EventBus(),
size: 9,
isSmall: false,
};
},
});
await makeView({
type: "form",
resModel: "project",
serverData,
arch: `
<form string="Settings" class="oe_form_configuration o_base_settings" js_class="base_settings">
<app string="CRM" name="crm">
<block>
<setting help="this is bar">
<field name="bar"/>
</setting>
</block>
</app>
<app string="Project" name="project">
<block>
<setting help="this is foo">
<field name="foo"/>
</setting>
</block>
</app>
</form>`,
});
await swipeLeft(target, ".settings");
execRegisteredTimeouts();
await nextTick();
assert.hasAttrValue(
target.querySelector(".selected"),
"data-key",
"crm",
"current setting should still be crm"
);
}
);
});

View file

@ -0,0 +1,65 @@
/** @odoo-module alias=@web/../tests/mobile/webclient/window_action_tests default=false */
import { getFixture } from "@web/../tests/helpers/utils";
import { setupViewRegistries } from "@web/../tests/views/helpers";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
let serverData, target;
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = {
models: {
project: {
fields: {
foo: { string: "Foo", type: "boolean" },
},
records: [
{
id: 1,
foo: true,
},
{
id: 2,
foo: false,
},
],
},
},
views: {
"project,false,list": '<list><field name="foo"/></list>',
"project,false,kanban": `
<kanban>
<templates>
<t t-name='card'>
<field name='foo' />
</t>
</templates>
</kanban>
`,
"project,false,search": "<search></search>",
},
};
target = getFixture();
setupViewRegistries();
});
QUnit.module("Window Actions");
QUnit.test("execute a window action with mobile_view_mode", async (assert) => {
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
xml_id: "project.action",
name: "Project Action",
res_model: "project",
type: "ir.actions.act_window",
view_mode: "list,kanban",
mobile_view_mode: "list",
views: [
[false, "kanban"],
[false, "list"],
],
});
assert.containsOnce(target, ".o_list_view");
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
/** @odoo-module alias=@web/../tests/patch_translations default=false */
import { translatedTerms, translationLoaded } from "@web/core/l10n/translation";
translatedTerms[translationLoaded] = true;

View file

@ -0,0 +1,57 @@
/** @odoo-module **/
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { getFixture, nextTick } from "@web/../tests/helpers/utils";
import { DEBOUNCE, makeAsyncHandler, makeButtonHandler } from '@web/legacy/js/public/minimal_dom';
QUnit.module('core', {}, function () {
QUnit.module('MinimalDom');
QUnit.test('MakeButtonHandler does not retrigger the same error', async function (assert) {
assert.expect(1);
assert.expectErrors();
// create a target for the button handler
const fixture = getFixture();
const button = document.createElement("button");
fixture.appendChild(button);
registerCleanup(() => { button.remove(); });
// get a way to reject the promise later
let rejectPromise;
const buttonHandler = makeButtonHandler(() => new Promise((resolve, reject) => {
rejectPromise = reject;
}));
// trigger the handler
buttonHandler({ target: button });
// wait for the button effect has been applied before rejecting the promise
await new Promise(res => setTimeout(res, DEBOUNCE + 1));
rejectPromise(new Error("reject"));
// check that there was only one unhandledrejection error
await nextTick();
assert.verifyErrors(["reject"]);
});
QUnit.test('MakeAsyncHandler does not retrigger the same error', async function (assert) {
assert.expect(1);
assert.expectErrors();
// get a way to reject the promise later
let rejectPromise;
const asyncHandler = makeAsyncHandler(() => new Promise((resolve, reject) => {
rejectPromise = reject;
}));
// trigger the handler
asyncHandler();
rejectPromise(new Error("reject"));
// check that there was only one unhandledrejection error
await nextTick();
assert.verifyErrors(["reject"]);
});
});

View file

@ -0,0 +1,364 @@
import publicWidget from "@web/legacy/js/public/public_widget";
import testUtils from "@web/../tests/legacy_tests/helpers/test_utils";
import { renderToString } from "@web/core/utils/render";
const Widget = publicWidget.Widget;
QUnit.module('core', {}, function () {
QUnit.module('Widget');
QUnit.test('proxy (String)', function (assert) {
assert.expect(1);
var W = Widget.extend({
exec: function () {
this.executed = true;
}
});
var w = new W();
var fn = w.proxy('exec');
fn();
assert.ok(w.executed, 'should execute the named method in the right context');
w.destroy();
});
QUnit.test('proxy (String)(*args)', function (assert) {
assert.expect(2);
var W = Widget.extend({
exec: function (arg) {
this.executed = arg;
}
});
var w = new W();
var fn = w.proxy('exec');
fn(42);
assert.ok(w.executed, "should execute the named method in the right context");
assert.strictEqual(w.executed, 42, "should be passed the proxy's arguments");
w.destroy();
});
QUnit.test('proxy (String), include', function (assert) {
assert.expect(1);
// the proxy function should handle methods being changed on the class
// and should always proxy "by name", to the most recent one
var W = Widget.extend({
exec: function () {
this.executed = 1;
}
});
var w = new W();
var fn = w.proxy('exec');
W.include({
exec: function () { this.executed = 2; }
});
fn();
assert.strictEqual(w.executed, 2, "should be lazily resolved");
w.destroy();
});
QUnit.test('proxy (Function)', function (assert) {
assert.expect(1);
var w = new (Widget.extend({ }))();
var fn = w.proxy(function () { this.executed = true; });
fn();
assert.ok(w.executed, "should set the function's context (like Function#bind)");
w.destroy();
});
QUnit.test('proxy (Function)(*args)', function (assert) {
assert.expect(1);
var w = new (Widget.extend({ }))();
var fn = w.proxy(function (arg) { this.executed = arg; });
fn(42);
assert.strictEqual(w.executed, 42, "should be passed the proxy's arguments");
w.destroy();
});
QUnit.test('renderElement, no template, default', function (assert) {
assert.expect(7);
var widget = new (Widget.extend({ }))();
assert.strictEqual(widget.$el, undefined, "should not have a root element");
widget.renderElement();
assert.ok(widget.$el, "should have generated a root element");
assert.strictEqual(widget.$el, widget.$el, "should provide $el alias");
assert.ok(widget.$el.is(widget.el), "should provide raw DOM alias");
assert.strictEqual(widget.el.nodeName, 'DIV', "should have generated the default element");
assert.strictEqual(widget.el.attributes.length, 0, "should not have generated any attribute");
assert.ok(Object.keys(widget.$el.html() || {}).length === 0, "should not have generated any content");
widget.destroy();
});
QUnit.test('no template, custom tag', function (assert) {
assert.expect(1);
var widget = new (Widget.extend({
tagName: 'ul'
}))();
widget.renderElement();
assert.strictEqual(widget.el.nodeName, 'UL', "should have generated the custom element tag");
widget.destroy();
});
QUnit.test('no template, @id', function (assert) {
assert.expect(3);
var widget = new (Widget.extend({
id: 'foo'
}))();
widget.renderElement();
assert.strictEqual(widget.el.attributes.length, 1, "should have one attribute");
assert.hasAttrValue(widget.$el, 'id', 'foo', "should have generated the id attribute");
assert.strictEqual(widget.el.id, 'foo', "should also be available via property");
widget.destroy();
});
QUnit.test('no template, @className', function (assert) {
assert.expect(2);
var widget = new (Widget.extend({
className: 'oe_some_class'
}))();
widget.renderElement();
assert.strictEqual(widget.el.className, 'oe_some_class', "should have the right property");
assert.hasAttrValue(widget.$el, 'class', 'oe_some_class', "should have the right attribute");
widget.destroy();
});
QUnit.test('no template, bunch of attributes', function (assert) {
assert.expect(9);
var widget = new (Widget.extend({
attributes: {
'id': 'some_id',
'class': 'some_class',
'data-foo': 'data attribute',
'clark': 'gable',
'spoiler': // don't read the next line if you care about Harry Potter...
'snape kills dumbledore'
}
}))();
widget.renderElement();
assert.strictEqual(widget.el.attributes.length, 5, "should have all the specified attributes");
assert.strictEqual(widget.el.id, 'some_id');
assert.hasAttrValue(widget.$el, 'id', 'some_id');
assert.strictEqual(widget.el.className, 'some_class');
assert.hasAttrValue(widget.$el, 'class', 'some_class');
assert.hasAttrValue(widget.$el, 'data-foo', 'data attribute');
assert.strictEqual(widget.$el.data('foo'), 'data attribute');
assert.hasAttrValue(widget.$el, 'clark', 'gable');
assert.hasAttrValue(widget.$el, 'spoiler', 'snape kills dumbledore');
widget.destroy();
});
QUnit.test('template', function (assert) {
assert.expect(3);
renderToString.app.addTemplate(
"test.widget.template.1",
`<ol>
<li t-foreach="[0, 1, 2, 3, 4]" t-as="counter" t-key="counter_index" t-attf-class="class-#{counter}">
<input/>
<t t-esc="counter"/>
</li>
</ol>`
);
var widget = new (Widget.extend({
template: 'test.widget.template.1'
}))();
widget.renderElement();
assert.strictEqual(widget.el.nodeName, 'OL');
assert.strictEqual(widget.$el.children().length, 5);
assert.strictEqual(widget.el.textContent, '01234');
widget.destroy();
});
QUnit.test('repeated', async function (assert) {
assert.expect(4);
var $fix = $( "#qunit-fixture");
renderToString.app.addTemplate(
"test.widget.template.2",
`<p>
<t t-esc="widget.value"/>
</p>`
);
var widget = new (Widget.extend({
template: 'test.widget.template.2'
}))();
widget.value = 42;
await widget.appendTo($fix)
.then(function () {
assert.strictEqual($fix.find('p').text(), '42', "DOM fixture should contain initial value");
assert.strictEqual(widget.$el.text(), '42', "should set initial value");
widget.value = 36;
widget.renderElement();
assert.strictEqual($fix.find('p').text(), '36', "DOM fixture should use new value");
assert.strictEqual(widget.$el.text(), '36', "should set new value");
});
widget.destroy();
});
QUnit.module('Widgets, with QWeb');
QUnit.test('basic-alias', function (assert) {
assert.expect(1);
renderToString.app.addTemplate(
"test.widget.template.3",
`<ol>
<li t-foreach="[0,1,2,3,4]" t-as="counter" t-key="counter_index" t-attf-class="class-#{counter}">
<input/>
<t t-esc="counter"/>
</li>
</ol>`
);
var widget = new (Widget.extend({
template: 'test.widget.template.3'
}))();
widget.renderElement();
assert.ok(widget.$('li:eq(3)').is(widget.$el.find('li:eq(3)')),
"should do the same thing as calling find on the widget root");
widget.destroy();
});
QUnit.test('delegate', async function (assert) {
assert.expect(5);
renderToString.app.addTemplate(
"test.widget.template.4",
`<ol>
<li t-foreach="[0,1,2,3,4]" t-as="counter" t-key="counter_index" t-attf-class="class-#{counter}">
<input/>
<t t-esc="counter"/>
</li>
</ol>`
);
var a = [];
var widget = new (Widget.extend({
template: 'test.widget.template.4',
events: {
'click': function () {
a[0] = true;
assert.strictEqual(this, widget, "should trigger events in widget");
},
'click li.class-3': 'class3',
'change input': function () { a[2] = true; }
},
class3: function () { a[1] = true; }
}))();
widget.renderElement();
await testUtils.dom.click(widget.$el, {allowInvisible: true});
await testUtils.dom.click(widget.$('li:eq(3)'), {allowInvisible: true});
await testUtils.fields.editAndTrigger(widget.$('input:last'), 'foo', 'change');
for(var i=0; i<3; ++i) {
assert.ok(a[i], "should pass test " + i);
}
widget.destroy();
});
QUnit.test('undelegate', async function (assert) {
assert.expect(4);
renderToString.app.addTemplate(
"test.widget.template.5",
`<ol>
<li t-foreach="[0,1,2,3,4]" t-as="counter" t-key="counter_index" t-attf-class="class-#{counter}">
<input/>
<t t-esc="counter"/>
</li>
</ol>`
);
var clicked = false;
var newclicked = false;
var widget = new (Widget.extend({
template: 'test.widget.template.5',
events: { 'click li': function () { clicked = true; } }
}))();
widget.renderElement();
widget.$el.on('click', 'li', function () { newclicked = true; });
await testUtils.dom.clickFirst(widget.$('li'), {allowInvisible: true});
assert.ok(clicked, "should trigger bound events");
assert.ok(newclicked, "should trigger bound events");
clicked = newclicked = false;
widget._undelegateEvents();
await testUtils.dom.clickFirst(widget.$('li'), {allowInvisible: true});
assert.ok(!clicked, "undelegate should unbind events delegated");
assert.ok(newclicked, "undelegate should only unbind events it created");
widget.destroy();
});
QUnit.module('Widget, and async stuff');
QUnit.test('start is not called when widget is destroyed', function (assert) {
assert.expect(0);
const $fix = $("#qunit-fixture");
// Note: willStart is always async
const MyWidget = Widget.extend({
start: function () {
assert.ok(false, 'Should not call start method');
},
});
const widget = new MyWidget();
widget.appendTo($fix);
widget.destroy();
const divEl = document.createElement('div');
$fix[0].appendChild(divEl);
const widget2 = new MyWidget();
widget2.attachTo(divEl);
widget2.destroy();
});
QUnit.test("don't destroy twice widget's children", function (assert) {
assert.expect(2);
var parent = new Widget();
new (Widget.extend({
destroy: function () {
assert.step('destroy');
}
}))(parent);
parent.destroy();
assert.verifySteps(['destroy'], "child should have been detroyed only once");
});
});

View file

@ -0,0 +1,667 @@
/** @odoo-module alias=@web/../tests/qunit default=false */
import { isVisible as isElemVisible } from "@web/core/utils/ui";
import { fullTraceback, fullAnnotatedTraceback } from "@web/core/errors/error_utils";
import { registry } from "@web/core/registry";
import { Component, whenReady } from "@odoo/owl";
const consoleError = console.error;
function setQUnitDebugMode() {
whenReady(() => document.body.classList.add("debug")); // make the test visible to the naked eye
QUnit.config.debug = true; // allows for helper functions to behave differently (logging, the HTML element in which the test occurs etc...)
QUnit.config.testTimeout = 60 * 60 * 1000;
// Allows for interacting with the test when it is over
// In fact, this will pause QUnit.
// Also, logs useful info in the console.
QUnit.testDone(async (...args) => {
console.groupCollapsed("Debug Test output");
console.log(...args);
console.groupEnd();
await new Promise(() => {});
});
}
// need to do this outside of the setup function so the QUnit.debug is defined when we need it
QUnit.debug = (name, cb) => {
setQUnitDebugMode();
QUnit.only(name, cb);
};
// need to do this outside of the setup function so it is executed quickly
QUnit.config.autostart = false;
export function setupQUnit() {
// -----------------------------------------------------------------------------
// QUnit config
// -----------------------------------------------------------------------------
QUnit.config.testTimeout = 1 * 60 * 1000;
QUnit.config.hidepassed = window.location.href.match(/[?&]testId=/) === null;
// -----------------------------------------------------------------------------
// QUnit assert
// -----------------------------------------------------------------------------
/**
* Checks that the target contains exactly n matches for the selector.
*
* Example: assert.containsN(document.body, '.modal', 0)
*/
function containsN(target, selector, n, msg) {
let $el;
if (target._widgetRenderAndInsert) {
$el = target.$el; // legacy widget
} else if (target instanceof Component) {
if (!target.el) {
throw new Error(
`containsN assert with selector '${selector}' called on an unmounted component`
);
}
$el = $(target.el);
} else {
$el = target instanceof Element ? $(target) : target;
}
msg = msg || `Selector '${selector}' should have exactly ${n} matches inside the target`;
QUnit.assert.strictEqual($el.find(selector).length, n, msg);
}
/**
* Checks that the target contains exactly 0 match for the selector.
*
* @param {Element} el
* @param {string} selector
* @param {string} [msg]
*/
function containsNone(target, selector, msg) {
containsN(target, selector, 0, msg);
}
/**
* Checks that the target contains exactly 1 match for the selector.
*
* @param {Element} el
* @param {string} selector
* @param {string} [msg]
*/
function containsOnce(target, selector, msg) {
containsN(target, selector, 1, msg);
}
/**
* Helper function, to check if a given element has (or has not) classnames.
*
* @private
* @param {Element | jQuery | Widget} el
* @param {string} classNames
* @param {boolean} shouldHaveClass
* @param {string} [msg]
*/
function _checkClass(el, classNames, shouldHaveClass, msg) {
if (el) {
if (el._widgetRenderAndInsert) {
el = el.el; // legacy widget
} else if (!(el instanceof Element)) {
el = el[0];
}
}
msg =
msg ||
`target should ${shouldHaveClass ? "have" : "not have"} classnames ${classNames}`;
const isFalse = classNames.split(" ").some((cls) => {
const hasClass = el.classList.contains(cls);
return shouldHaveClass ? !hasClass : hasClass;
});
QUnit.assert.ok(!isFalse, msg);
}
/**
* Checks that the target element has the given classnames.
*
* @param {Element} el
* @param {string} classNames
* @param {string} [msg]
*/
function hasClass(el, classNames, msg) {
_checkClass(el, classNames, true, msg);
}
/**
* Checks that the target element does not have the given classnames.
*
* @param {Element} el
* @param {string} classNames
* @param {string} [msg]
*/
function doesNotHaveClass(el, classNames, msg) {
_checkClass(el, classNames, false, msg);
}
/**
* Checks that the target element (described by widget/jquery or html element)
* - exists
* - is unique
* - has the given attribute with the proper value
*
* @param {Component | Element | Widget | jQuery} w
* @param {string} attr
* @param {string} value
* @param {string} [msg]
*/
function hasAttrValue(target, attr, value, msg) {
let $el;
if (target._widgetRenderAndInsert) {
$el = target.$el; // legacy widget
} else if (target instanceof Component) {
if (!target.el) {
throw new Error(
`hasAttrValue assert with attr '${attr}' called on an unmounted component`
);
}
$el = $(target.el);
} else {
$el = target instanceof Element ? $(target) : target;
}
if ($el.length !== 1) {
const descr = `hasAttrValue (${attr}: ${value})`;
QUnit.assert.ok(
false,
`Assertion '${descr}' targets ${$el.length} elements instead of 1`
);
} else {
msg = msg || `attribute '${attr}' of target should be '${value}'`;
QUnit.assert.strictEqual($el.attr(attr), value, msg);
}
}
/**
* Helper function, to check if a given element
* - is unique (if it is a jquery node set)
* - is (or not) visible
*
* @private
* @param {Element | jQuery | Widget} el
* @param {boolean} shouldBeVisible
* @param {string} [msg]
*/
function _checkVisible(el, shouldBeVisible, msg) {
if (el) {
if (el._widgetRenderAndInsert) {
el = el.el; // legacy widget
} else if (!(el instanceof Element)) {
el = el[0];
}
}
msg = msg || `target should ${shouldBeVisible ? "" : "not"} be visible`;
const _isVisible = isElemVisible(el);
const condition = shouldBeVisible ? _isVisible : !_isVisible;
QUnit.assert.ok(condition, msg);
}
function isVisible(el, msg) {
return _checkVisible(el, true, msg);
}
function isNotVisible(el, msg) {
return _checkVisible(el, false, msg);
}
function expectErrors() {
QUnit.config.current.expectErrors = true;
QUnit.config.current.unverifiedErrors = [];
}
function verifyErrors(expectedErrors) {
if (!QUnit.config.current.expectErrors) {
QUnit.pushFailure(`assert.expectErrors() must be called at the beginning of the test`);
return;
}
const unverifiedErrors = QUnit.config.current.unverifiedErrors;
QUnit.config.current.assert.deepEqual(unverifiedErrors, expectedErrors, "verifying errors");
QUnit.config.current.unverifiedErrors = [];
}
QUnit.assert.containsN = containsN;
QUnit.assert.containsNone = containsNone;
QUnit.assert.containsOnce = containsOnce;
QUnit.assert.doesNotHaveClass = doesNotHaveClass;
QUnit.assert.hasClass = hasClass;
QUnit.assert.hasAttrValue = hasAttrValue;
QUnit.assert.isVisible = isVisible;
QUnit.assert.isNotVisible = isNotVisible;
QUnit.assert.expectErrors = expectErrors;
QUnit.assert.verifyErrors = verifyErrors;
// -----------------------------------------------------------------------------
// QUnit logs
// -----------------------------------------------------------------------------
/**
* If we want to log several errors, we have to log all of them at once, as
* browser_js is closed as soon as an error is logged.
*/
let errorMessages = [];
async function logErrors() {
const messages = errorMessages.slice();
errorMessages = [];
const infos = await Promise.all(messages);
consoleError(infos.map((info) => info.error || info).join("\n"));
// Only log the source of the errors in "info" log level to allow matching the same
// error with its log message, as source contains asset file name which changes
console.info(
infos
.map((info) =>
info.source ? `${info.error}\n${info.source.replace(/^/gm, "\t")}\n` : info
)
.join("\n")
);
}
/**
* If we want to log several errors, we have to log all of them at once, as
* browser_js is closed as soon as an error is logged.
*/
QUnit.done(async (result) => {
await odoo.loader.checkErrorProm;
const moduleLoadingError = document.querySelector(".o_module_error");
if (moduleLoadingError) {
errorMessages.unshift(moduleLoadingError.innerText);
}
if (result.failed) {
errorMessages.push(`${result.failed} / ${result.total} tests failed.`);
}
if (!result.failed && !moduleLoadingError) {
// use console.dir for this log to appear on runbot sub-builds page
console.dir(
`QUnit: passed ${testPassedCount} tests (${
result.passed
} assertions), took ${Math.round(result.runtime / 1000)}s`
);
console.log("QUnit test suite done.");
} else {
logErrors();
}
});
/**
* This is done mostly for the .txt log file generated by the runbot.
*/
QUnit.moduleDone(async (result) => {
if (!result.failed) {
console.log(
`"${result.name}" passed ${result.tests.length} tests (${result.total} assertions).`
);
} else {
console.log(
'"' + result.name + '"',
"failed",
result.failed,
"tests out of",
result.total,
"."
);
}
});
/**
* This logs various data in the console, which will be available in the log
* .txt file generated by the runbot.
*/
QUnit.log((result) => {
if (result.result) {
return;
}
errorMessages.push(
Promise.resolve(result.annotateProm).then(() => {
let info = `QUnit test failed: ${result.module} > ${result.name} :`;
if (result.message) {
info += `\n\tmessage: "${result.message}"`;
}
if ("expected" in result) {
info += `\n\texpected: "${result.expected}"`;
}
if (result.actual !== null) {
info += `\n\tactual: "${result.actual}"`;
}
return {
error: info,
source: result.source,
};
})
);
});
/**
* The purpose of this function is to reset the timer nesting level of the execution context
* to 0, to prevent situations where a setTimeout with a timeout of 0 may end up being
* scheduled after another one that also has a timeout of 0 that was called later.
* Example code:
* (async () => {
* const timeout = () => new Promise((resolve) => setTimeout(resolve, 0));
* const animationFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
*
* for (let i = 0; i < 4; i++) {
* await timeout();
* }
* timeout().then(() => console.log("after timeout"));
* await animationFrame()
* timeout().then(() => console.log("after animationFrame"));
* // logs "after animationFrame" before "after timeout"
* })()
*
* When the browser runs a task that was the result of a timer (setTimeout or setInterval),
* that task has an intrinsic "timer nesting level". If you schedule another task with
* a timer from within such a task, the new task has the existing task's timer nesting level,
* plus one. When the timer nesting level of a task is greater than 5, the `timeout` parameter
* for setTimeout/setInterval will be forced to at least 4 (see step 5 in the timer initialization
* steps in the HTML spec: https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timer-initialisation-steps).
*
* In the above example, every `await timeout()` besides inside the loop schedules a new task
* from within a task that was initiated by a timer, causing the nesting level to be 5 after
* the loop. The first timeout after the loop is now forced to 4.
*
* When we await the animation frame promise, we create a task that is *not* initiated by a timer,
* reseting the nesting level to 0, causing the timeout following it to properly be treated as 0,
* as such the callback that was registered by it is oftentimes executed before the previous one.
*
* While we can't prevent this from happening within a given test, we want to at least prevent
* the timer nesting level to propagate from one test to the next as this can be a cause of
* indeterminism. To avoid slowing down the tests by waiting one frame after every test,
* we instead use a MessageChannel to add a task with not nesting level to the event queue immediately.
*/
QUnit.testDone(async () => {
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => {
channel.port1.close();
channel.port2.close();
resolve();
};
channel.port2.postMessage("");
});
});
// Append a "Rerun in debug" link.
// Only works if the test is not hidden.
QUnit.testDone(async ({ testId }) => {
if (errorMessages.length > 0) {
logErrors();
}
const testElement = document.getElementById(`qunit-test-output-${testId}`);
if (!testElement) {
// Is probably hidden because it passed
return;
}
const reRun = testElement.querySelector("li a");
const reRunDebug = document.createElement("a");
reRunDebug.textContent = "Rerun in debug";
const url = new URL(window.location);
url.searchParams.set("testId", testId);
url.searchParams.set("debugTest", "true");
reRunDebug.setAttribute("href", url.href);
reRun.parentElement.insertBefore(reRunDebug, reRun.nextSibling);
});
const debugTest = new URLSearchParams(location.search).get("debugTest");
if (debugTest) {
setQUnitDebugMode();
}
// Override global UnhandledRejection that is assigned wayyy before this file
// Do not really crash on non-errors rejections
const qunitUnhandledReject = QUnit.onUnhandledRejection;
QUnit.onUnhandledRejection = (reason) => {
const error = reason instanceof Error && "cause" in reason ? reason.cause : reason;
if (error instanceof Error) {
qunitUnhandledReject(reason);
}
};
// Essentially prevents default error logging when the rejection was
// not due to an actual error
const windowUnhandledReject = window.onunhandledrejection;
window.onunhandledrejection = (ev) => {
const error =
ev.reason instanceof Error && "cause" in ev.reason ? ev.reason.cause : ev.reason;
if (!(error instanceof Error)) {
ev.stopImmediatePropagation();
ev.preventDefault();
} else if (windowUnhandledReject) {
windowUnhandledReject.call(window, ev);
}
};
// -----------------------------------------------------------------------------
// FailFast
// -----------------------------------------------------------------------------
/**
* We add here a 'fail fast' feature: we often want to stop the test suite after
* the first failed test. This is also useful for the runbot test suites.
*/
QUnit.config.urlConfig.push({
id: "failfast",
label: "Fail Fast",
tooltip: "Stop the test suite immediately after the first failed test.",
});
QUnit.begin(function () {
if (odoo.debug && odoo.debug.includes("assets")) {
QUnit.annotateTraceback = fullAnnotatedTraceback;
} else {
QUnit.annotateTraceback = (err) => Promise.resolve(fullTraceback(err));
}
const config = QUnit.config;
if (config.failfast) {
QUnit.testDone(function (details) {
if (details.failed > 0) {
config.queue.length = 0;
}
});
}
});
// -----------------------------------------------------------------------------
// Add sort button
// -----------------------------------------------------------------------------
let sortButtonAppended = false;
/**
* Add a sort button on top of the QUnit result page, so we can see which tests
* take the most time.
*/
function addSortButton() {
sortButtonAppended = true;
var $sort = $("<label> sort by time (desc)</label>").css({ float: "right" });
$("h2#qunit-userAgent").append($sort);
$sort.click(function () {
var $ol = $("ol#qunit-tests");
var $results = $ol.children("li").get();
$results.sort(function (a, b) {
var timeA = Number($(a).find("span.runtime").first().text().split(" ")[0]);
var timeB = Number($(b).find("span.runtime").first().text().split(" ")[0]);
if (timeA < timeB) {
return 1;
} else if (timeA > timeB) {
return -1;
} else {
return 0;
}
});
$.each($results, function (idx, $itm) {
$ol.append($itm);
});
});
}
QUnit.done(() => {
if (!sortButtonAppended) {
addSortButton();
}
});
// -----------------------------------------------------------------------------
// Add statistics
// -----------------------------------------------------------------------------
let passedEl;
let failedEl;
let skippedEl;
let todoCompletedEl;
let todoUncompletedEl;
function insertStats() {
const toolbar = document.querySelector("#qunit-testrunner-toolbar .qunit-url-config");
const statsEl = document.createElement("label");
passedEl = document.createElement("span");
passedEl.classList.add("text-success", "ms-5", "me-3");
statsEl.appendChild(passedEl);
todoCompletedEl = document.createElement("span");
todoCompletedEl.classList.add("text-warning", "me-3");
statsEl.appendChild(todoCompletedEl);
failedEl = document.createElement("span");
failedEl.classList.add("text-danger", "me-3");
statsEl.appendChild(failedEl);
todoUncompletedEl = document.createElement("span");
todoUncompletedEl.classList.add("text-primary", "me-3");
statsEl.appendChild(todoUncompletedEl);
skippedEl = document.createElement("span");
skippedEl.classList.add("text-dark");
statsEl.appendChild(skippedEl);
toolbar.appendChild(statsEl);
}
let testPassedCount = 0;
let testFailedCount = 0;
let testSkippedCount = 0;
let todoCompletedCount = 0;
let todoUncompletedCount = 0;
QUnit.testDone(({ skipped, failed, todo }) => {
if (!passedEl) {
insertStats();
}
if (!skipped) {
if (failed > 0) {
if (todo) {
todoUncompletedCount++;
} else {
testFailedCount++;
}
} else {
if (todo) {
todoCompletedCount++;
} else {
testPassedCount++;
}
}
} else {
testSkippedCount++;
}
passedEl.innerText = `${testPassedCount} passed`;
if (todoCompletedCount > 0) {
todoCompletedEl.innerText = `${todoCompletedCount} todo completed`;
}
if (todoUncompletedCount > 0) {
todoUncompletedEl.innerText = `${todoUncompletedCount} todo uncompleted`;
}
if (testFailedCount > 0) {
failedEl.innerText = `${testFailedCount} failed`;
}
if (testSkippedCount > 0) {
skippedEl.innerText = `${testSkippedCount} skipped`;
}
});
// -----------------------------------------------------------------------------
// FIXME: This sounds stupid, it feels stupid... but it fixes visibility check in folded <details> since Chromium 97+ 💩
// Since https://bugs.chromium.org/p/chromium/issues/detail?id=1185950
// See regression report https://bugs.chromium.org/p/chromium/issues/detail?id=1276028
// -----------------------------------------------------------------------------
QUnit.begin(() => {
const el = document.createElement("style");
el.innerText = "details:not([open]) > :not(summary) { display: none; }";
document.head.appendChild(el);
});
// -----------------------------------------------------------------------------
// Error management
// -----------------------------------------------------------------------------
QUnit.on("OdooAfterTestHook", (info) => {
const { expectErrors, unverifiedErrors } = QUnit.config.current;
if (expectErrors && unverifiedErrors.length) {
QUnit.pushFailure(
`Expected assert.verifyErrors() to be called before end of test. Unverified errors: ${unverifiedErrors}`
);
}
});
const { onUnhandledRejection } = QUnit;
QUnit.onUnhandledRejection = () => {};
QUnit.onError = () => {};
console.error = function () {
if (QUnit.config.current) {
QUnit.pushFailure(`console.error called with "${arguments[0]}"`);
} else {
consoleError(...arguments);
}
};
function onUncaughtErrorInTest(error) {
if (!QUnit.config.current.expectErrors) {
// we did not expect any error, so notify qunit to add a failure
onUnhandledRejection(error);
} else {
// we expected errors, so store it, it will be checked later (see verifyErrors)
while (error instanceof Error && "cause" in error) {
error = error.cause;
}
QUnit.config.current.unverifiedErrors.push(error.message);
}
}
// e.g. setTimeout(() => throw new Error()) (event handler crashes synchronously)
window.addEventListener("error", async (ev) => {
if (!QUnit.config.current) {
return; // we are not in a test -> do nothing
}
// do not log to the console as this will kill python test early
ev.preventDefault();
// if the error service is deployed, we'll get to the patched default handler below if no
// other handler handled the error, so do nothing here
if (registry.category("services").get("error", false)) {
return;
}
if (
ev.message === "ResizeObserver loop limit exceeded" ||
ev.message === "ResizeObserver loop completed with undelivered notifications."
) {
return;
}
onUncaughtErrorInTest(ev.error);
});
// e.g. Promise.resolve().then(() => throw new Error()) (crash in event handler after async boundary)
window.addEventListener("unhandledrejection", async (ev) => {
if (!QUnit.config.current) {
return; // we are not in a test -> do nothing
}
// do not log to the console as this will kill python test early
ev.preventDefault();
// if the error service is deployed, we'll get to the patched default handler below if no
// other handler handled the error, so do nothing here
if (registry.category("services").get("error", false)) {
return;
}
onUncaughtErrorInTest(ev.reason);
});
// This is an approximation, but we can't directly import the default error handler, because
// it's not the same in all tested environments (e.g. /web and /pos), so we get the last item
// from the handler registry and assume it is the default one, which handles all "not already
// handled" errors, like tracebacks.
const errorHandlerRegistry = registry.category("error_handlers");
const [defaultHandlerName, defaultHandler] = errorHandlerRegistry.getEntries().at(-1);
const testDefaultHandler = (env, uncaughtError, originalError) => {
onUncaughtErrorInTest(originalError);
return defaultHandler(env, uncaughtError, originalError);
};
errorHandlerRegistry.add(defaultHandlerName, testDefaultHandler, {
sequence: Number.POSITIVE_INFINITY,
force: true,
});
}

View file

@ -0,0 +1,326 @@
/** @odoo-module alias=@web/../tests/search/helpers default=false */
import { Component, xml } from "@odoo/owl";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import {
click,
editInput,
getFixture,
mount,
mouseEnter,
triggerEvent,
triggerEvents,
} from "@web/../tests/helpers/utils";
import { commandService } from "@web/core/commands/command_service";
import { dialogService } from "@web/core/dialog/dialog_service";
import { fieldService } from "@web/core/field_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { notificationService } from "@web/core/notifications/notification_service";
import { ormService } from "@web/core/orm_service";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { CustomFavoriteItem } from "@web/search/custom_favorite_item/custom_favorite_item";
import { WithSearch } from "@web/search/with_search/with_search";
import { getDefaultConfig } from "@web/views/view";
import { viewService } from "@web/views/view_service";
import { actionService } from "@web/webclient/actions/action_service";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { nameService } from "@web/core/name_service";
import { datetimePickerService } from "@web/core/datetime/datetimepicker_service";
const serviceRegistry = registry.category("services");
const favoriteMenuRegistry = registry.category("favoriteMenu");
export function setupControlPanelServiceRegistry() {
serviceRegistry.add("action", actionService);
serviceRegistry.add("dialog", dialogService);
serviceRegistry.add("field", fieldService);
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("name", nameService);
serviceRegistry.add("notification", notificationService);
serviceRegistry.add("orm", ormService);
serviceRegistry.add("popover", popoverService);
serviceRegistry.add("view", viewService);
serviceRegistry.add("command", commandService);
serviceRegistry.add("datetime_picker", datetimePickerService);
}
export function setupControlPanelFavoriteMenuRegistry() {
favoriteMenuRegistry.add(
"custom-favorite-item",
{ Component: CustomFavoriteItem, groupNumber: 3 },
{ sequence: 0 }
);
}
export async function makeWithSearch(params) {
const props = { ...params };
const serverData = props.serverData || undefined;
const mockRPC = props.mockRPC || undefined;
const config = {
...getDefaultConfig(),
...props.config,
};
delete props.serverData;
delete props.mockRPC;
delete props.config;
const componentProps = props.componentProps || {};
delete props.componentProps;
delete props.Component;
class Parent extends Component {
static template = xml`
<WithSearch t-props="withSearchProps" t-slot-scope="search">
<Component t-props="getProps(search)"/>
</WithSearch>
<MainComponentsContainer />
`;
static components = { Component: params.Component, WithSearch, MainComponentsContainer };
static props = ["*"];
setup() {
this.withSearchProps = props;
}
getProps(search) {
const props = Object.assign({}, componentProps, {
context: search.context,
domain: search.domain,
groupBy: search.groupBy,
orderBy: search.orderBy,
comparison: search.comparison,
display: Object.assign({}, search.display, componentProps.display),
});
return filterPropsForComponent(params.Component, props);
}
}
const env = await makeTestEnv({ serverData, mockRPC });
const searchEnv = Object.assign(Object.create(env), { config });
const parent = await mount(Parent, getFixture(), { env: searchEnv, props });
const parentNode = parent.__owl__;
const withSearchNode = getUniqueChild(parentNode);
const componentNode = getUniqueChild(withSearchNode);
const component = componentNode.component;
return component;
}
/** This function is aim to be used only in the tests.
* It will filter the props that are needed by the Component.
* This is to avoid errors of props validation. This occurs for example, on ControlPanel tests.
* In production, View use WithSearch for the Controllers, and the Layout send only the props that
* need to the ControlPanel.
*
* @param {Component} Component
* @param {Object} props
* @returns {Object} filtered props
*/
function filterPropsForComponent(Component, props) {
// This if, can be removed once all the Components have the props defined
if (Component.props) {
let componentKeys = null;
if (Component.props instanceof Array) {
componentKeys = Component.props.map((x) => x.replace("?", ""));
} else {
componentKeys = Object.keys(Component.props);
}
if (componentKeys.includes("*")) {
return props;
} else {
return Object.keys(props)
.filter((k) => componentKeys.includes(k))
.reduce((o, k) => {
o[k] = props[k];
return o;
}, {});
}
} else {
return props;
}
}
function getUniqueChild(node) {
return Object.values(node.children)[0];
}
function getNode(target) {
return target instanceof Component ? target.el : target;
}
export function findItem(target, selector, finder = 0) {
const el = getNode(target);
const elems = [...el.querySelectorAll(selector)];
if (Number.isInteger(finder)) {
return elems[finder];
}
return elems.find((el) => el.innerText.trim().toLowerCase() === String(finder).toLowerCase());
}
/** Menu (generic) */
export async function toggleMenu(el, menuFinder) {
const menu = findItem(el, `button.dropdown-toggle`, menuFinder);
await click(menu);
}
export async function toggleMenuItem(el, itemFinder) {
const item = findItem(el, `.o_menu_item`, itemFinder);
if (item.classList.contains("dropdown-toggle")) {
await mouseEnter(item);
} else {
await click(item);
}
}
export async function toggleMenuItemOption(el, itemFinder, optionFinder) {
const item = findItem(el, `.o_menu_item`, itemFinder);
const option = findItem(item.parentNode, ".o_item_option", optionFinder);
if (option.classList.contains("dropdown-toggle")) {
await mouseEnter(option);
} else {
await click(option);
}
}
export function isItemSelected(el, itemFinder) {
const item = findItem(el, `.o_menu_item`, itemFinder);
return item.classList.contains("selected");
}
export function isOptionSelected(el, itemFinder, optionFinder) {
const item = findItem(el, `.o_menu_item`, itemFinder);
const option = findItem(item.parentNode, ".o_item_option", optionFinder);
return option.classList.contains("selected");
}
export function getMenuItemTexts(target) {
const el = getNode(target);
return [...el.querySelectorAll(`.dropdown-menu .o_menu_item`)].map((e) => e.innerText.trim());
}
export function getVisibleButtons(el) {
return [
...$(el).find(
[
"div.o_control_panel_breadcrumbs button:visible", // button in the breadcrumbs
"div.o_control_panel_actions button:visible", // buttons for list selection
].join(",")
),
];
}
/** Filter menu */
export async function openAddCustomFilterDialog(el) {
await click(findItem(el, `.o_filter_menu .o_menu_item.o_add_custom_filter`));
}
/** Group by menu */
export async function selectGroup(el, fieldName) {
el.querySelector(".o_add_custom_group_menu").value = fieldName;
await triggerEvent(el, ".o_add_custom_group_menu", "change");
}
export async function groupByMenu(el, fieldName) {
await toggleSearchBarMenu(el);
await selectGroup(el, fieldName);
}
/** Favorite menu */
export async function deleteFavorite(el, favoriteFinder) {
const favorite = findItem(el, `.o_favorite_menu .o_menu_item`, favoriteFinder);
await click(findItem(favorite, "i.fa-trash-o"));
}
export async function toggleSaveFavorite(el) {
await click(findItem(el, `.o_favorite_menu .o_add_favorite`));
}
export async function editFavoriteName(el, name) {
const input = findItem(
el,
`.o_favorite_menu .o_add_favorite + .o_accordion_values input[type="text"]`
);
input.value = name;
await triggerEvents(input, null, ["input", "change"]);
}
export async function saveFavorite(el) {
await click(findItem(el, `.o_favorite_menu .o_add_favorite + .o_accordion_values button`));
}
/** Search bar */
export function getFacetTexts(target) {
const el = getNode(target);
return [...el.querySelectorAll(`div.o_searchview_facet`)].map((facet) =>
facet.innerText.trim()
);
}
export async function removeFacet(el, facetFinder = 0) {
const facet = findItem(el, `div.o_searchview_facet`, facetFinder);
await click(facet.querySelector(".o_facet_remove"));
}
export async function editSearch(el, value) {
const input = findItem(el, `.o_searchview input`);
input.value = value;
await triggerEvent(input, null, "input");
}
export async function validateSearch(el) {
const input = findItem(el, `.o_searchview input`);
await triggerEvent(input, null, "keydown", { key: "Enter" });
}
/** Switch View */
export async function switchView(el, viewType) {
await click(findItem(el, `button.o_switch_view.o_${viewType}`));
}
/** Pager */
export function getPagerValue(el) {
const valueEl = findItem(el, ".o_pager .o_pager_value");
return valueEl.innerText.trim().split("-").map(Number);
}
export function getPagerLimit(el) {
const limitEl = findItem(el, ".o_pager .o_pager_limit");
return Number(limitEl.innerText.trim());
}
export async function pagerNext(el) {
await click(findItem(el, ".o_pager button.o_pager_next"));
}
export async function pagerPrevious(el) {
await click(findItem(el, ".o_pager button.o_pager_previous"));
}
export async function editPager(el, value) {
await click(findItem(el, ".o_pager .o_pager_value"));
await editInput(getNode(el), ".o_pager .o_pager_value.o_input", value);
}
/////////////////////////////////////
// Action Menu
/////////////////////////////////////
/**
* @param {EventTarget} el
* @param {string} [menuFinder="Action"]
* @returns {Promise}
*/
export async function toggleActionMenu(el) {
await click(el.querySelector(".o_cp_action_menus .dropdown-toggle"));
}
/** SearchBarMenu */
export async function toggleSearchBarMenu(el) {
await click(findItem(el, `.o_searchview_dropdown_toggler`));
}

View file

@ -0,0 +1,404 @@
/** @odoo-module alias=@web/../tests/setup default=false */
import { assets } from "@web/core/assets";
import { user, _makeUser } from "@web/core/user";
import { browser, makeRAMLocalStorage } from "@web/core/browser/browser";
import { patchTimeZone, patchWithCleanup } from "@web/../tests/helpers/utils";
import { memoize } from "@web/core/utils/functions";
import { registerCleanup } from "./helpers/cleanup";
import { prepareRegistriesWithCleanup } from "./helpers/mock_env";
import { session as sessionInfo } from "@web/session";
import { config as transitionConfig } from "@web/core/transition";
import { loadLanguages } from "@web/core/l10n/translation";
transitionConfig.disabled = true;
import { patch } from "@web/core/utils/patch";
import { App, EventBus, whenReady } from "@odoo/owl";
import { currencies } from "@web/core/currency";
import { cookie } from "@web/core/browser/cookie";
import { router } from "@web/core/browser/router";
import { registerTemplateProcessor } from "@web/core/templates";
function forceLocaleAndTimezoneWithCleanup() {
const originalLocale = luxon.Settings.defaultLocale;
luxon.Settings.defaultLocale = "en";
registerCleanup(() => {
luxon.Settings.defaultLocale = originalLocale;
});
patchTimeZone(60);
}
function makeMockLocation() {
return Object.assign(document.createElement("a"), {
href: window.location.origin + "/odoo",
assign(url) {
this.href = url;
},
reload() {},
});
}
function patchOwlApp() {
patchWithCleanup(App.prototype, {
destroy() {
if (!this.destroyed) {
super.destroy(...arguments);
this.destroyed = true;
}
},
});
}
function patchCookie() {
const cookieJar = {};
patchWithCleanup(cookie, {
get _cookieMonster() {
return Object.entries(cookieJar)
.filter(([, value]) => value !== "kill")
.map((cookie) => cookie.join("="))
.join("; ");
},
set _cookieMonster(value) {
const cookies = value.split("; ");
for (const cookie of cookies) {
const [key, value] = cookie.split(/=(.*)/);
if (!["path", "max-age"].includes(key)) {
cookieJar[key] = value;
}
}
},
});
}
function patchBrowserWithCleanup() {
const originalAddEventListener = browser.addEventListener;
const originalRemoveEventListener = browser.removeEventListener;
const originalSetTimeout = browser.setTimeout;
const originalClearTimeout = browser.clearTimeout;
const originalSetInterval = browser.setInterval;
const originalClearInterval = browser.clearInterval;
let nextAnimationFrameHandle = 1;
const animationFrameHandles = new Set();
const mockLocation = makeMockLocation();
let historyStack = [[null, mockLocation.href]];
let currentHistoryStack = 0;
patchWithCleanup(browser, {
// patch addEventListner to automatically remove listeners bound (via
// browser.addEventListener) during a test (e.g. during the deployment of a service)
addEventListener() {
originalAddEventListener(...arguments);
registerCleanup(() => {
originalRemoveEventListener(...arguments);
});
},
// patch setTimeout to automatically remove timeouts bound (via
// browser.setTimeout) during a test (e.g. during the deployment of a service)
setTimeout() {
const timeout = originalSetTimeout(...arguments);
registerCleanup(() => {
originalClearTimeout(timeout);
});
return timeout;
},
// patch setInterval to automatically remove callbacks registered (via
// browser.setInterval) during a test (e.g. during the deployment of a service)
setInterval() {
const interval = originalSetInterval(...arguments);
registerCleanup(() => {
originalClearInterval(interval);
});
return interval;
},
// patch BeforeInstallPromptEvent to prevent the pwa service to return an uncontrolled
// canPromptToInstall value depending the browser settings (we ensure the value is always falsy)
BeforeInstallPromptEvent: undefined,
navigator: {
mediaDevices: browser.navigator.mediaDevices,
permissions: browser.navigator.permissions,
userAgent: browser.navigator.userAgent.replace(/\([^)]*\)/, "(X11; Linux x86_64)"),
sendBeacon: () => {
throw new Error("sendBeacon called in test but not mocked");
},
},
// in tests, we never want to interact with the real url or reload the page
location: mockLocation,
history: {
pushState(state, title, url) {
historyStack = historyStack.slice(0, currentHistoryStack + 1);
historyStack.push([state, url]);
currentHistoryStack++;
mockLocation.assign(url);
},
replaceState(state, title, url) {
historyStack[currentHistoryStack] = [state, url];
mockLocation.assign(url);
},
back() {
currentHistoryStack--;
const [state, url] = historyStack[currentHistoryStack];
if (!url) {
throw new Error("there is no history");
}
mockLocation.assign(url);
window.dispatchEvent(new PopStateEvent("popstate", { state }));
},
forward() {
currentHistoryStack++;
const [state, url] = historyStack[currentHistoryStack];
if (!url) {
throw new Error("No more history");
}
mockLocation.assign(url);
window.dispatchEvent(new PopStateEvent("popstate", { state }));
},
get length() {
return historyStack.length;
},
},
// in tests, we never want to interact with the real local/session storages.
localStorage: makeRAMLocalStorage(),
sessionStorage: makeRAMLocalStorage(),
// Don't want original animation frames in tests
requestAnimationFrame: (fn) => {
const handle = nextAnimationFrameHandle++;
animationFrameHandles.add(handle);
Promise.resolve().then(() => {
if (animationFrameHandles.has(handle)) {
fn(16);
}
});
return handle;
},
cancelAnimationFrame: (handle) => {
animationFrameHandles.delete(handle);
},
// BroadcastChannels need to be closed to be garbage collected
BroadcastChannel: class SelfClosingBroadcastChannel extends BroadcastChannel {
constructor() {
super(...arguments);
registerCleanup(() => this.close());
}
},
// XHR: we don't want tests to do real RPCs
XMLHttpRequest: class MockXHR {
constructor() {
throw new Error("XHR not patched in a test. Consider using patchRPCWithCleanup.");
}
},
});
}
function patchBodyAddEventListener() {
// In some cases, e.g. tooltip service, event handlers are registered on document.body and not
// browser, because the events we listen to aren't triggered on window. We want to clear those
// handlers as well after each test.
const originalBodyAddEventListener = document.body.addEventListener;
const originalBodyRemoveEventListener = document.body.removeEventListener;
document.body.addEventListener = function () {
originalBodyAddEventListener.call(this, ...arguments);
registerCleanup(() => {
originalBodyRemoveEventListener.call(this, ...arguments);
});
};
registerCleanup(() => {
document.body.addEventListener = originalBodyAddEventListener;
});
}
function patchOdoo() {
patchWithCleanup(odoo, {
debug: "",
});
}
function cleanLoadedLanguages() {
registerCleanup(() => {
loadLanguages.installedLanguages = null;
});
}
function patchSessionInfo() {
patchWithCleanup(sessionInfo, {
cache_hashes: {
load_menus: "161803",
translations: "314159",
},
qweb: "owl",
// Commit: 3e847fc8f499c96b8f2d072ab19f35e105fd7749
// to see what user_companies is
user_companies: {
allowed_companies: { 1: { id: 1, name: "Hermit" } },
current_company: 1,
},
user_context: {
lang: "en",
tz: "taht",
},
db: "test",
is_admin: true,
is_system: true,
username: "thewise@odoo.com",
name: "Mitchell",
partner_id: 7,
uid: 7,
server_version: "1.0",
server_version_info: [1, 0, 0, "final", 0, ""],
});
const mockedUser = _makeUser(sessionInfo);
patchWithCleanup(user, mockedUser);
patchWithCleanup(user, { hasGroup: () => Promise.resolve(false) });
patchWithCleanup(currencies, {
1: { name: "USD", digits: [69, 2], position: "before", symbol: "$" },
2: { name: "EUR", digits: [69, 2], position: "after", symbol: "€" },
});
}
function replaceAttr(attrName, prefix, element) {
const attrKey = `${prefix}${attrName}`;
const attrValue = element.getAttribute(attrKey);
element.removeAttribute(attrKey);
element.setAttribute(`${prefix}data-${attrName}`, attrValue);
}
registerTemplateProcessor((template) => {
// We remove all the attributes `src` and `alt` from the template and replace them by
// data attributes (e.g. `src` to `data-src`, `alt` to `data-alt`).
// alt attribute causes issues with scroll tests. Indeed, alt is
// displayed between the time we scroll programmatically and the time
// we assert for the scroll position. The src attribute is removed
// as well to make sure images won't trigger a GET request on the
// server.
for (const attrName of ["alt", "src"]) {
for (const prefix of ["", "t-att-", "t-attf-"]) {
for (const element of template.querySelectorAll(`*[${prefix}${attrName}]`)) {
replaceAttr(attrName, prefix, element);
}
}
}
});
function patchAssets() {
const { getBundle, loadJS, loadCSS } = assets;
patch(assets, {
getBundle: memoize(async function (xmlID) {
console.log(
"%c[assets] fetch libs from xmlID: " + xmlID,
"color: #66e; font-weight: bold;"
);
return getBundle(xmlID);
}),
loadJS: memoize(async function (ressource) {
if (ressource.match(/\/static(\/\S+\/|\/)libs?/)) {
console.log(
"%c[assets] fetch (mock) JS ressource: " + ressource,
"color: #66e; font-weight: bold;"
);
return Promise.resolve();
}
console.log(
"%c[assets] fetch JS ressource: " + ressource,
"color: #66e; font-weight: bold;"
);
return loadJS(ressource);
}),
loadCSS: memoize(async function (ressource) {
if (ressource.match(/\/static(\/\S+\/|\/)libs?/)) {
console.log(
"%c[assets] fetch (mock) CSS ressource: " + ressource,
"color: #66e; font-weight: bold;"
);
return Promise.resolve();
}
console.log(
"%c[assets] fetch CSS ressource: " + ressource,
"color: #66e; font-weight: bold;"
);
return loadCSS(ressource);
}),
});
}
function patchEventBus() {
patchWithCleanup(EventBus.prototype, {
addEventListener() {
super.addEventListener(...arguments);
registerCleanup(() => this.removeEventListener(...arguments));
},
});
}
export async function setupTests() {
// uncomment to debug memory leaks in qunit suite
// if (window.gc) {
// let memoryBeforeModule;
// QUnit.moduleStart(({ tests }) => {
// if (tests.length) {
// window.gc();
// memoryBeforeModule = window.performance.memory.usedJSHeapSize;
// }
// });
// QUnit.moduleDone(({ name }) => {
// if (memoryBeforeModule) {
// window.gc();
// const afterGc = window.performance.memory.usedJSHeapSize;
// console.log(
// `MEMINFO - After suite "${name}" - after gc: ${afterGc} delta: ${
// afterGc - memoryBeforeModule
// }`
// );
// memoryBeforeModule = null;
// }
// });
// }
QUnit.testStart(() => {
prepareRegistriesWithCleanup();
forceLocaleAndTimezoneWithCleanup();
cleanLoadedLanguages();
patchBrowserWithCleanup();
registerCleanup(router.cancelPushes);
patchCookie();
patchBodyAddEventListener();
patchEventBus();
patchOdoo();
patchSessionInfo();
patchOwlApp();
});
await whenReady();
patchAssets();
// make sure images do not trigger a GET on the server
new MutationObserver((mutations) => {
const nodes = mutations.flatMap(({ target }) => {
if (target.nodeName === "IMG" || target.nodeName === "IFRAME") {
return target;
}
return [
...target.getElementsByTagName("img"),
...target.getElementsByTagName("iframe"),
];
});
for (const node of nodes) {
const src = node.getAttribute("src");
if (src && src !== "about:blank") {
node.dataset.src = src;
if (node.nodeName === "IMG") {
node.removeAttribute("src");
} else {
node.setAttribute("src", "about:blank");
}
node.dispatchEvent(new Event("load"));
}
}
}).observe(document.body, {
subtree: true,
childList: true,
attributeFilter: ["src"],
});
}

View file

@ -0,0 +1,685 @@
/** @odoo-module alias=@web/../tests/utils default=false */
import { isVisible } from "@web/core/utils/ui";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import {
click as webClick,
getFixture,
makeDeferred,
triggerEvents as webTriggerEvents,
} from "@web/../tests/helpers/utils";
/**
* 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,
items: [],
types: ["Files"],
};
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then clicks on it.
*
* @param {string} selector
* @param {ContainsOptions} [options] forwarded to `contains`
* @param {boolean} [options.shiftKey]
*/
export async function click(selector, options = {}) {
const { shiftKey } = options;
delete options.shiftKey;
await contains(selector, { click: { shiftKey }, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then dragenters `files` on it.
*
* @param {string} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function dragenterFiles(selector, files, options) {
await contains(selector, { dragenterFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then dragovers `files` on it.
*
* @param {string} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function dragoverFiles(selector, files, options) {
await contains(selector, { dragoverFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then drops `files` on it.
*
* @param {string} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function dropFiles(selector, files, options) {
await contains(selector, { dropFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then inputs `files` on it.
*
* @param {string} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function inputFiles(selector, files, options) {
await contains(selector, { inputFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then pastes `files` on it.
*
* @param {string} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function pasteFiles(selector, files, options) {
await contains(selector, { pasteFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then focuses on it.
*
* @param {string} selector
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function focus(selector, options) {
await contains(selector, { setFocus: true, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then inserts the given `content`.
*
* @param {string} selector
* @param {string} content
* @param {ContainsOptions} [options] forwarded to `contains`
* @param {boolean} [options.replace=false]
*/
export async function insertText(selector, content, options = {}) {
const { replace = false } = options;
delete options.replace;
await contains(selector, { ...options, insertText: { content, replace } });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then sets its `scrollTop` to the given value.
*
* @param {string} selector
* @param {number|"bottom"} scrollTop
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function scroll(selector, scrollTop, options) {
await contains(selector, { setScroll: scrollTop, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then triggers `event` on it.
*
* @param {string} selector
* @param {(import("@web/../tests/helpers/utils").EventType|[import("@web/../tests/helpers/utils").EventType, EventInit])[]} events
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function triggerEvents(selector, events, options) {
await contains(selector, { triggerEvents: events, ...options });
}
function log(ok, message) {
if (window.QUnit) {
QUnit.assert.ok(ok, message);
} else {
if (ok) {
console.log(message);
} else {
console.error(message);
}
}
}
let hasUsedContainsPositively = false;
if (window.QUnit) {
QUnit.testStart(() => (hasUsedContainsPositively = false));
}
/**
* @typedef {[string, ContainsOptions]} ContainsTuple tuple representing params of the contains
* function, where the first element is the selector, and the second element is the options param.
* @typedef {Object} ContainsOptions
* @property {ContainsTuple} [after] if provided, the found element(s) must be after the element
* matched by this param.
* @property {ContainsTuple} [before] if provided, the found element(s) must be before the element
* matched by this param.
* @property {Object} [click] if provided, clicks on the first found element
* @property {ContainsTuple|ContainsTuple[]} [contains] if provided, the found element(s) must
* contain the provided sub-elements.
* @property {number} [count=1] numbers of elements to be found to declare the contains check
* as successful. Elements are counted after applying all other filters.
* @property {Object[]} [dragenterFiles] if provided, dragenters the given files on the found element
* @property {Object[]} [dragoverFiles] if provided, dragovers the given files on the found element
* @property {Object[]} [dropFiles] if provided, drops the given files on the found element
* @property {Object[]} [inputFiles] if provided, inputs the given files on the found element
* @property {{content:string, replace:boolean}} [insertText] if provided, adds to (or replace) the
* value of the first found element by the given content.
* @property {ContainsTuple} [parent] if provided, the found element(s) must have as
* parent the node matching the parent parameter.
* @property {Object[]} [pasteFiles] if provided, pastes the given files on the found element
* @property {number|"bottom"} [scroll] if provided, the scrollTop of the found element(s)
* must match.
* Note: when using one of the scrollTop options, it is advised to ensure the height is not going
* to change soon, by checking with a preceding contains that all the expected elements are in DOM.
* @property {boolean} [setFocus] if provided, focuses the first found element.
* @property {boolean} [shadowRoot] if provided, targets the shadowRoot of the found elements.
* @property {number|"bottom"} [setScroll] if provided, sets the scrollTop on the first found
* element.
* @property {HTMLElement} [target=getFixture()]
* @property {string[]} [triggerEvents] if provided, triggers the given events on the found element
* @property {string} [text] if provided, the textContent of the found element(s) or one of their
* descendants must match. Use `textContent` option for a match on the found element(s) only.
* @property {string} [textContent] if provided, the textContent of the found element(s) must match.
* Prefer `text` option for a match on the found element(s) or any of their descendants, usually
* allowing for a simpler and less specific selector.
* @property {string} [value] if provided, the input value of the found element(s) must match.
* Note: value changes are not observed directly, another mutation must happen to catch them.
* @property {boolean} [visible] if provided, the found element(s) must be (in)visible
*/
class Contains {
/**
* @param {string} selector
* @param {ContainsOptions} [options={}]
*/
constructor(selector, options = {}) {
this.selector = selector;
this.options = options;
this.options.count ??= 1;
this.options.targetParam = this.options.target;
this.options.target ??= getFixture();
let selectorMessage = `${this.options.count} of "${this.selector}"`;
if (this.options.visible !== undefined) {
selectorMessage = `${selectorMessage} ${
this.options.visible ? "visible" : "invisible"
}`;
}
if (this.options.targetParam) {
selectorMessage = `${selectorMessage} inside a specific target`;
}
if (this.options.parent) {
selectorMessage = `${selectorMessage} inside a specific parent`;
}
if (this.options.contains) {
selectorMessage = `${selectorMessage} with a specified sub-contains`;
}
if (this.options.text !== undefined) {
selectorMessage = `${selectorMessage} with text "${this.options.text}"`;
}
if (this.options.textContent !== undefined) {
selectorMessage = `${selectorMessage} with textContent "${this.options.textContent}"`;
}
if (this.options.value !== undefined) {
selectorMessage = `${selectorMessage} with value "${this.options.value}"`;
}
if (this.options.scroll !== undefined) {
selectorMessage = `${selectorMessage} with scroll "${this.options.scroll}"`;
}
if (this.options.after !== undefined) {
selectorMessage = `${selectorMessage} after a specified element`;
}
if (this.options.before !== undefined) {
selectorMessage = `${selectorMessage} before a specified element`;
}
this.selectorMessage = selectorMessage;
if (this.options.contains && !Array.isArray(this.options.contains[0])) {
this.options.contains = [this.options.contains];
}
if (this.options.count) {
hasUsedContainsPositively = true;
} else if (!hasUsedContainsPositively) {
throw new Error(
`Starting a test with "contains" of count 0 for selector "${this.selector}" is useless because it might immediately resolve. Start the test by checking that an expected element actually exists.`
);
}
/** @type {string} */
this.successMessage = undefined;
/** @type {function} */
this.executeError = undefined;
}
/**
* Starts this contains check, either immediately resolving if there is a
* match, or registering appropriate listeners and waiting until there is a
* match or a timeout (resolving or rejecting respectively).
*
* Success or failure messages will be logged with QUnit as well.
*
* @returns {Promise}
*/
run() {
this.done = false;
this.def = makeDeferred();
this.scrollListeners = new Set();
this.onScroll = () => this.runOnce("after scroll");
if (!this.runOnce("immediately")) {
this.timer = setTimeout(
() => this.runOnce("Timeout of 5 seconds", { crashOnFail: true }),
5000
);
this.observer = new MutationObserver((mutations) => {
try {
this.runOnce("after mutations");
} catch (e) {
this.def.reject(e); // prevents infinite loop in case of programming error
}
});
this.observer.observe(this.options.target, {
attributes: true,
childList: true,
subtree: true,
});
registerCleanup(() => {
if (!this.done) {
this.runOnce("Test ended", { crashOnFail: true });
}
});
}
return this.def;
}
/**
* Runs this contains check once, immediately returning the result (or
* undefined), and possibly resolving or rejecting the main promise
* (and printing QUnit log) depending on options.
* If undefined is returned it means the check was not successful.
*
* @param {string} whenMessage
* @param {Object} [options={}]
* @param {boolean} [options.crashOnFail=false]
* @param {boolean} [options.executeOnSuccess=true]
* @returns {HTMLElement[]|undefined}
*/
runOnce(whenMessage, { crashOnFail = false, executeOnSuccess = true } = {}) {
const res = this.select();
if (res?.length === this.options.count || crashOnFail) {
// clean before doing anything else to avoid infinite loop due to side effects
this.observer?.disconnect();
clearTimeout(this.timer);
for (const el of this.scrollListeners ?? []) {
el.removeEventListener("scroll", this.onScroll);
}
this.done = true;
}
if (res?.length === this.options.count) {
this.successMessage = `Found ${this.selectorMessage} (${whenMessage})`;
if (executeOnSuccess) {
this.executeAction(res[0]);
}
return res;
} else {
this.executeError = () => {
let message = `Failed to find ${this.selectorMessage} (${whenMessage}).`;
message = res
? `${message} Found ${res.length} instead.`
: `${message} Parent not found.`;
if (this.parentContains) {
if (this.parentContains.successMessage) {
log(true, this.parentContains.successMessage);
} else {
this.parentContains.executeError();
}
}
log(false, message);
this.def?.reject(new Error(message));
for (const childContains of this.childrenContains || []) {
if (childContains.successMessage) {
log(true, childContains.successMessage);
} else {
childContains.executeError();
}
}
};
if (crashOnFail) {
this.executeError();
}
}
}
/**
* Executes the action(s) given to this constructor on the found element,
* prints the success messages, and resolves the main deferred.
* @param {HTMLElement} el
*/
executeAction(el) {
let message = this.successMessage;
if (this.options.click) {
message = `${message} and clicked it`;
webClick(el, undefined, {
mouseEventInit: this.options.click,
skipDisabledCheck: true,
skipVisibilityCheck: true,
});
}
if (this.options.dragenterFiles) {
message = `${message} and dragentered ${this.options.dragenterFiles.length} file(s)`;
const ev = new Event("dragenter", { bubbles: true });
Object.defineProperty(ev, "dataTransfer", {
value: createFakeDataTransfer(this.options.dragenterFiles),
});
el.dispatchEvent(ev);
}
if (this.options.dragoverFiles) {
message = `${message} and dragovered ${this.options.dragoverFiles.length} file(s)`;
const ev = new Event("dragover", { bubbles: true });
Object.defineProperty(ev, "dataTransfer", {
value: createFakeDataTransfer(this.options.dragoverFiles),
});
el.dispatchEvent(ev);
}
if (this.options.dropFiles) {
message = `${message} and dropped ${this.options.dropFiles.length} file(s)`;
const ev = new Event("drop", { bubbles: true });
Object.defineProperty(ev, "dataTransfer", {
value: createFakeDataTransfer(this.options.dropFiles),
});
el.dispatchEvent(ev);
}
if (this.options.inputFiles) {
message = `${message} and inputted ${this.options.inputFiles.length} file(s)`;
// 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 this.options.inputFiles) {
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"));
}
}
if (this.options.insertText !== undefined) {
message = `${message} and inserted text "${this.options.insertText.content}" (replace: ${this.options.insertText.replace})`;
el.focus();
if (this.options.insertText.replace) {
el.value = "";
el.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Backspace" }));
el.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Backspace" }));
el.dispatchEvent(new window.InputEvent("input"));
}
for (const char of this.options.insertText.content) {
el.value += char;
el.dispatchEvent(new window.KeyboardEvent("keydown", { key: char }));
el.dispatchEvent(new window.KeyboardEvent("keyup", { key: char }));
el.dispatchEvent(new window.InputEvent("input"));
}
el.dispatchEvent(new window.InputEvent("change"));
}
if (this.options.pasteFiles) {
message = `${message} and pasted ${this.options.pasteFiles.length} file(s)`;
const ev = new Event("paste", { bubbles: true });
Object.defineProperty(ev, "clipboardData", {
value: createFakeDataTransfer(this.options.pasteFiles),
});
el.dispatchEvent(ev);
}
if (this.options.setFocus) {
message = `${message} and focused it`;
el.focus();
}
if (this.options.setScroll !== undefined) {
message = `${message} and set scroll to "${this.options.setScroll}"`;
el.scrollTop =
this.options.setScroll === "bottom" ? el.scrollHeight : this.options.setScroll;
}
if (this.options.triggerEvents) {
message = `${message} and triggered "${this.options.triggerEvents.join(", ")}" events`;
webTriggerEvents(el, null, this.options.triggerEvents, {
skipVisibilityCheck: true,
});
}
if (this.parentContains) {
log(true, this.parentContains.successMessage);
}
log(true, message);
for (const childContains of this.childrenContains) {
log(true, childContains.successMessage);
}
this.def?.resolve();
}
/**
* Returns the found element(s) according to this constructor setup.
* If undefined is returned it means the parent cannot be found
*
* @returns {HTMLElement[]|undefined}
*/
select() {
const target = this.selectParent();
if (!target) {
return;
}
const baseRes = [...target.querySelectorAll(this.selector)]
.map((el) => (this.options.shadowRoot ? el.shadowRoot : el))
.filter((el) => el);
/** @type {Contains[]} */
this.childrenContains = [];
const res = baseRes.filter((el, currentIndex) => {
let condition =
(this.options.textContent === undefined ||
el.textContent.trim() === this.options.textContent) &&
(this.options.value === undefined || el.value === this.options.value) &&
(this.options.scroll === undefined ||
(this.options.scroll === "bottom"
? Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) <= 1
: Math.abs(el.scrollTop - this.options.scroll) <= 1));
if (condition && this.options.text !== undefined) {
if (
el.textContent.trim() !== this.options.text &&
[...el.querySelectorAll("*")].every(
(el) => el.textContent.trim() !== this.options.text
)
) {
condition = false;
}
}
if (condition && this.options.contains) {
for (const param of this.options.contains) {
const childContains = new Contains(param[0], { ...param[1], target: el });
if (
!childContains.runOnce(`as child of el ${currentIndex + 1})`, {
executeOnSuccess: false,
})
) {
condition = false;
}
this.childrenContains.push(childContains);
}
}
if (condition && this.options.visible !== undefined) {
if (isVisible(el) !== this.options.visible) {
condition = false;
}
}
if (condition && this.options.after) {
const afterContains = new Contains(this.options.after[0], {
...this.options.after[1],
target,
});
const afterEl = afterContains.runOnce(`as "after"`, {
executeOnSuccess: false,
})?.[0];
if (
!afterEl ||
!(el.compareDocumentPosition(afterEl) & Node.DOCUMENT_POSITION_PRECEDING)
) {
condition = false;
}
this.childrenContains.push(afterContains);
}
if (condition && this.options.before) {
const beforeContains = new Contains(this.options.before[0], {
...this.options.before[1],
target,
});
const beforeEl = beforeContains.runOnce(`as "before"`, {
executeOnSuccess: false,
})?.[0];
if (
!beforeEl ||
!(el.compareDocumentPosition(beforeEl) & Node.DOCUMENT_POSITION_FOLLOWING)
) {
condition = false;
}
this.childrenContains.push(beforeContains);
}
return condition;
});
if (
this.options.scroll !== undefined &&
this.scrollListeners &&
baseRes.length === this.options.count &&
res.length !== this.options.count
) {
for (const el of baseRes) {
if (!this.scrollListeners.has(el)) {
this.scrollListeners.add(el);
el.addEventListener("scroll", this.onScroll);
}
}
}
return res;
}
/**
* Returns the found element that should act as the target (parent) for the
* main selector.
* If undefined is returned it means the parent cannot be found.
*
* @returns {HTMLElement|undefined}
*/
selectParent() {
if (this.options.parent) {
this.parentContains = new Contains(this.options.parent[0], {
...this.options.parent[1],
target: this.options.target,
});
return this.parentContains.runOnce(`as parent`, { executeOnSuccess: false })?.[0];
}
return this.options.target;
}
}
/**
* Waits until `count` elements matching the given `selector` are present in
* `options.target`.
*
* @param {string} selector
* @param {ContainsOptions} [options]
* @returns {Promise}
*/
export async function contains(selector, options) {
await new Contains(selector, options).run();
}
const stepState = {
expectedSteps: null,
deferred: null,
timeout: null,
currentSteps: [],
clear() {
clearTimeout(this.timeout);
this.timeout = null;
this.deferred = null;
this.currentSteps = [];
this.expectedSteps = null;
},
check({ crashOnFail = false } = {}) {
const success =
this.expectedSteps.length === this.currentSteps.length &&
this.expectedSteps.every((s, i) => s === this.currentSteps[i]);
if (!success && !crashOnFail) {
return;
}
QUnit.config.current.assert.verifySteps(this.expectedSteps);
if (success) {
this.deferred.resolve();
} else {
this.deferred.reject(new Error("Steps do not match."));
}
this.clear();
},
};
if (window.QUnit) {
QUnit.testStart(() =>
registerCleanup(() => {
if (stepState.expectedSteps) {
stepState.check({ crashOnFail: true });
} else {
stepState.clear();
}
})
);
}
/**
* Indicate the completion of a test step. This step must then be verified by
* calling `assertSteps`.
*
* @param {string} step
*/
export function step(step) {
stepState.currentSteps.push(step);
QUnit.config.current.assert.step(step);
if (stepState.expectedSteps) {
stepState.check();
}
}
/**
* Wait for the given steps to be executed or for the timeout to be reached.
*
* @param {string[]} steps
*/
export function assertSteps(steps) {
if (stepState.expectedSteps) {
stepState.check({ crashOnFail: true });
}
stepState.expectedSteps = steps;
stepState.deferred = makeDeferred();
stepState.timeout = setTimeout(() => stepState.check({ crashOnFail: true }), 2000);
stepState.check();
return stepState.deferred;
}

View file

@ -0,0 +1,557 @@
/** @odoo-module alias=@web/../tests/views/calendar/helpers default=false */
import { uiService } from "@web/core/ui/ui_service";
import { createElement } from "@web/core/utils/xml";
import { registry } from "@web/core/registry";
import { Field } from "@web/views/fields/field";
import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
import { click, getFixture, mount, nextTick, triggerEvent } from "../../helpers/utils";
import { setupViewRegistries } from "@web/../tests/views/helpers";
export function makeEnv(services = {}) {
clearRegistryWithCleanup(registry.category("main_components"));
setupViewRegistries();
services = Object.assign(
{
ui: uiService,
},
services
);
for (const [key, service] of Object.entries(services)) {
registry.category("services").add(key, service, { force: true });
}
return makeTestEnv({
config: {
setDisplayName: () => {},
},
});
}
//------------------------------------------------------------------------------
export async function mountComponent(C, env, props) {
const target = getFixture();
return await mount(C, target, { env, props });
}
//------------------------------------------------------------------------------
export function makeFakeDate() {
return luxon.DateTime.local(2021, 7, 16, 8, 0, 0, 0);
}
export function makeFakeRecords() {
return {
1: {
id: 1,
title: "1 day, all day in July",
start: makeFakeDate(),
isAllDay: true,
end: makeFakeDate(),
},
2: {
id: 2,
title: "3 days, all day in July",
start: makeFakeDate().plus({ days: 2 }),
isAllDay: true,
end: makeFakeDate().plus({ days: 4 }),
},
3: {
id: 3,
title: "1 day, all day in June",
start: makeFakeDate().plus({ months: -1 }),
isAllDay: true,
end: makeFakeDate().plus({ months: -1 }),
},
4: {
id: 4,
title: "3 days, all day in June",
start: makeFakeDate().plus({ months: -1, days: 2 }),
isAllDay: true,
end: makeFakeDate().plus({ months: -1, days: 4 }),
},
5: {
id: 5,
title: "Over June and July",
start: makeFakeDate().startOf("month").plus({ days: -2 }),
isAllDay: true,
end: makeFakeDate().startOf("month").plus({ days: 2 }),
},
};
}
export const FAKE_FILTER_SECTIONS = [
{
label: "Attendees",
fieldName: "partner_ids",
avatar: {
model: "res.partner",
field: "avatar_128",
},
hasAvatar: true,
write: {
model: "filter_partner",
field: "partner_id",
},
canCollapse: true,
canAddFilter: true,
filters: [
{
type: "user",
label: "Mitchell Admin",
active: true,
value: 3,
colorIndex: 3,
recordId: null,
canRemove: false,
hasAvatar: true,
},
{
type: "all",
label: "Everybody's calendar",
active: false,
value: "all",
colorIndex: null,
recordId: null,
canRemove: false,
hasAvatar: false,
},
{
type: "record",
label: "Brandon Freeman",
active: true,
value: 4,
colorIndex: 4,
recordId: 1,
canRemove: true,
hasAvatar: true,
},
{
type: "record",
label: "Marc Demo",
active: false,
value: 6,
colorIndex: 6,
recordId: 2,
canRemove: true,
hasAvatar: true,
},
],
},
{
label: "Users",
fieldName: "user_id",
avatar: {
model: null,
field: null,
},
hasAvatar: false,
write: {
model: null,
field: null,
},
canCollapse: false,
canAddFilter: false,
filters: [
{
type: "record",
label: "Brandon Freeman",
active: false,
value: 1,
colorIndex: false,
recordId: null,
canRemove: true,
hasAvatar: true,
},
{
type: "record",
label: "Marc Demo",
active: false,
value: 2,
colorIndex: false,
recordId: null,
canRemove: true,
hasAvatar: true,
},
],
},
];
export const FAKE_FIELDS = {
id: { string: "Id", type: "integer" },
user_id: { string: "User", type: "many2one", relation: "user", default: -1 },
partner_id: {
string: "Partner",
type: "many2one",
relation: "partner",
related: "user_id.partner_id",
default: 1,
},
name: { string: "Name", type: "char" },
start_date: { string: "Start Date", type: "date" },
stop_date: { string: "Stop Date", type: "date" },
start: { string: "Start Datetime", type: "datetime" },
stop: { string: "Stop Datetime", type: "datetime" },
delay: { string: "Delay", type: "float" },
allday: { string: "Is All Day", type: "boolean" },
partner_ids: {
string: "Attendees",
type: "one2many",
relation: "partner",
default: [[6, 0, [1]]],
},
type: { string: "Type", type: "integer" },
event_type_id: { string: "Event Type", type: "many2one", relation: "event_type" },
color: { string: "Color", type: "integer", related: "event_type_id.color" },
};
function makeFakeModelState() {
const fakeFieldNode = createElement("field", { name: "name" });
const fakeModels = { event: { fields: FAKE_FIELDS } };
return {
canCreate: true,
canDelete: true,
canEdit: true,
date: makeFakeDate(),
fieldMapping: {
date_start: "start_date",
date_stop: "stop_date",
date_delay: "delay",
all_day: "allday",
color: "color",
},
fieldNames: ["start_date", "stop_date", "color", "delay", "allday", "user_id"],
fields: FAKE_FIELDS,
filterSections: FAKE_FILTER_SECTIONS,
firstDayOfWeek: 0,
isDateHidden: false,
isTimeHidden: false,
hasAllDaySlot: true,
hasEditDialog: false,
quickCreate: false,
popoverFieldNodes: {
name: Field.parseFieldNode(fakeFieldNode, fakeModels, "event", "calendar"),
},
activeFields: {
name: {
context: "{}",
invisible: false,
readonly: false,
required: false,
onChange: false,
},
},
rangeEnd: makeFakeDate().endOf("month"),
rangeStart: makeFakeDate().startOf("month"),
records: makeFakeRecords(),
resModel: "event",
scale: "month",
scales: ["day", "week", "month", "year"],
unusualDays: [],
};
}
export function makeFakeModel(state = {}) {
return {
...makeFakeModelState(),
load() {},
createFilter() {},
createRecord() {},
unlinkFilter() {},
unlinkRecord() {},
updateFilter() {},
updateRecord() {},
...state,
};
}
// DOM Utils
//------------------------------------------------------------------------------
async function scrollTo(el, scrollParam) {
el.scrollIntoView(scrollParam);
await new Promise(window.requestAnimationFrame);
}
export function findPickedDate(target) {
return target.querySelector(".o_datetime_picker .o_selected");
}
export async function pickDate(target, date) {
const day = date.split("-")[2];
const iDay = parseInt(day, 10) - 1;
const el = target.querySelectorAll(`.o_datetime_picker .o_date_item_cell:not(.o_out_of_range)`)[
iDay
];
el.scrollIntoView();
await click(el);
}
export function expandCalendarView(target) {
// Expends Calendar view and FC too
let tmpElement = target.querySelector(".fc");
do {
tmpElement = tmpElement.parentElement;
tmpElement.classList.add("h-100");
} while (!tmpElement.classList.contains("o_view_controller"));
}
export function findAllDaySlot(target, date) {
return target.querySelector(`.fc-daygrid-body .fc-day[data-date="${date}"]`);
}
export function findDateCell(target, date) {
return target.querySelector(`.fc-day[data-date="${date}"]`);
}
export function findEvent(target, eventId) {
return target.querySelector(`.o_event[data-event-id="${eventId}"]`);
}
export function findDateCol(target, date) {
return target.querySelector(`.fc-col-header-cell.fc-day[data-date="${date}"]`);
}
export function findTimeRow(target, time) {
return target.querySelector(`.fc-timegrid-slot[data-time="${time}"]`);
}
export async function triggerEventForCalendar(el, type, position = {}) {
const rect = el.getBoundingClientRect();
const x = position.x || rect.x + rect.width / 2;
const y = position.y || rect.y + rect.height / 2;
const attrs = {
which: 1,
clientX: x,
clientY: y,
};
await triggerEvent(el, null, type, attrs);
}
export async function clickAllDaySlot(target, date) {
const el = findAllDaySlot(target, date);
await scrollTo(el);
await triggerEventForCalendar(el, "mousedown");
await triggerEventForCalendar(el, "mouseup");
await nextTick();
}
export async function clickDate(target, date) {
const el = findDateCell(target, date);
await scrollTo(el);
await triggerEventForCalendar(el, "mousedown");
await triggerEventForCalendar(el, "mouseup");
await nextTick();
}
export async function clickEvent(target, eventId) {
const el = findEvent(target, eventId);
await scrollTo(el);
await click(el);
await nextTick();
}
export async function selectTimeRange(target, startDateTime, endDateTime) {
const [startDate, startTime] = startDateTime.split(" ");
const [endDate, endTime] = endDateTime.split(" ");
const startCol = findDateCol(target, startDate);
const endCol = findDateCol(target, endDate);
const startRow = findTimeRow(target, startTime);
const endRow = findTimeRow(target, endTime);
await scrollTo(startRow);
const startColRect = startCol.getBoundingClientRect();
const startRowRect = startRow.getBoundingClientRect();
await triggerEventForCalendar(startRow, "mousedown", {
x: startColRect.x + startColRect.width / 2,
y: startRowRect.y + 2,
});
await scrollTo(endRow, false);
const endColRect = endCol.getBoundingClientRect();
const endRowRect = endRow.getBoundingClientRect();
await triggerEventForCalendar(endRow, "mousemove", {
x: endColRect.x + endColRect.width / 2,
y: endRowRect.y - 2,
});
await triggerEventForCalendar(endRow, "mouseup", {
x: endColRect.x + endColRect.width / 2,
y: endRowRect.y - 2,
});
await nextTick();
}
export async function selectDateRange(target, startDate, endDate) {
const start = findDateCell(target, startDate);
const end = findDateCell(target, endDate);
await scrollTo(start);
await triggerEventForCalendar(start, "mousedown");
await scrollTo(end);
await triggerEventForCalendar(end, "mousemove");
await triggerEventForCalendar(end, "mouseup");
await nextTick();
}
export async function selectAllDayRange(target, startDate, endDate) {
const start = findAllDaySlot(target, startDate);
const end = findAllDaySlot(target, endDate);
await scrollTo(start);
await triggerEventForCalendar(start, "mousedown");
await scrollTo(end);
await triggerEventForCalendar(end, "mousemove");
await triggerEventForCalendar(end, "mouseup");
await nextTick();
}
export async function moveEventToDate(target, eventId, date, options = {}) {
const event = findEvent(target, eventId);
const cell = findDateCell(target, date);
await scrollTo(event);
await triggerEventForCalendar(event, "mousedown");
await scrollTo(cell);
await triggerEventForCalendar(cell, "mousemove");
if (!options.disableDrop) {
await triggerEventForCalendar(cell, "mouseup");
}
await nextTick();
}
export async function moveEventToTime(target, eventId, dateTime) {
const event = findEvent(target, eventId);
const [date, time] = dateTime.split(" ");
const col = findDateCol(target, date);
const row = findTimeRow(target, time);
// Find event position
await scrollTo(event);
const eventRect = event.getBoundingClientRect();
const eventPos = {
x: eventRect.x + eventRect.width / 2,
y: eventRect.y,
};
await triggerEventForCalendar(event, "mousedown", eventPos);
// Find target position
await scrollTo(row, false);
const colRect = col.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const toPos = {
x: colRect.x + colRect.width / 2,
y: rowRect.y - 1,
};
await triggerEventForCalendar(row, "mousemove", toPos);
await triggerEventForCalendar(row, "mouseup", toPos);
await nextTick();
}
export async function moveEventToAllDaySlot(target, eventId, date) {
const event = findEvent(target, eventId);
const slot = findAllDaySlot(target, date);
// Find event position
await scrollTo(event);
const eventRect = event.getBoundingClientRect();
const eventPos = {
x: eventRect.x + eventRect.width / 2,
y: eventRect.y,
};
await triggerEventForCalendar(event, "mousedown", eventPos);
// Find target position
await scrollTo(slot);
const slotRect = slot.getBoundingClientRect();
const toPos = {
x: slotRect.x + slotRect.width / 2,
y: slotRect.y - 1,
};
await triggerEventForCalendar(slot, "mousemove", toPos);
await triggerEventForCalendar(slot, "mouseup", toPos);
await nextTick();
}
export async function resizeEventToTime(target, eventId, dateTime) {
const event = findEvent(target, eventId);
const [date, time] = dateTime.split(" ");
const col = findDateCol(target, date);
const row = findTimeRow(target, time);
// Find event position
await scrollTo(event);
await triggerEventForCalendar(event, "mouseover");
// Find event resizer
const resizer = event.querySelector(".fc-event-resizer-end");
resizer.style.display = "block";
resizer.style.width = "100%";
resizer.style.height = "1em";
resizer.style.bottom = "0";
const resizerRect = resizer.getBoundingClientRect();
const resizerPos = {
x: resizerRect.x + resizerRect.width / 2,
y: resizerRect.y + resizerRect.height / 2,
};
await triggerEventForCalendar(resizer, "mousedown", resizerPos);
// Find target position
await scrollTo(row, false);
const colRect = col.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const toPos = {
x: colRect.x + colRect.width / 2,
y: rowRect.y - 1,
};
await triggerEventForCalendar(row, "mousemove", toPos);
await triggerEventForCalendar(row, "mouseup", toPos);
await nextTick();
}
export async function changeScale(target, scale) {
await click(target, `.o_view_scale_selector .scale_button_selection`);
await click(target, `.o-dropdown--menu .o_scale_button_${scale}`);
await nextTick();
}
export async function navigate(target, direction) {
await click(target, `.o_calendar_navigation_buttons .o_calendar_button_${direction}`);
}
export function findFilterPanelSection(target, sectionName) {
return target.querySelector(`.o_calendar_filter[data-name="${sectionName}"]`);
}
export function findFilterPanelFilter(target, sectionName, filterValue) {
return findFilterPanelSection(target, sectionName).querySelector(
`.o_calendar_filter_item[data-value="${filterValue}"]`
);
}
export function findFilterPanelSectionFilter(target, sectionName) {
return findFilterPanelSection(target, sectionName).querySelector(
`.o_calendar_filter_items_checkall`
);
}
export async function toggleFilter(target, sectionName, filterValue) {
const el = findFilterPanelFilter(target, sectionName, filterValue).querySelector(`input`);
await scrollTo(el);
await click(el);
}
export async function toggleSectionFilter(target, sectionName) {
const el = findFilterPanelSectionFilter(target, sectionName).querySelector(`input`);
await scrollTo(el);
await click(el);
}

View file

@ -0,0 +1,44 @@
/** @odoo-module alias=@web/../tests/views/graph_view_tests default=false */
import { click, findChildren, triggerEvent } from "@web/../tests/helpers/utils";
import { ensureArray } from "@web/core/utils/arrays";
// TODO: remove when dependant modules are converted
export function checkLabels(assert, graph, expectedLabels) {
const labels = getGraphRenderer(graph).chart.data.labels.map((l) => l.toString());
assert.deepEqual(labels, expectedLabels);
}
export function checkLegend(assert, graph, expectedLegendLabels) {
expectedLegendLabels = ensureArray(expectedLegendLabels);
const { chart } = getGraphRenderer(graph);
const actualLegendLabels = chart.config.options.plugins.legend.labels
.generateLabels(chart)
.map((o) => o.text);
assert.deepEqual(actualLegendLabels, expectedLegendLabels);
}
export function clickOnDataset(graph) {
const { chart } = getGraphRenderer(graph);
const meta = chart.getDatasetMeta(0);
const rectangle = chart.canvas.getBoundingClientRect();
const point = meta.data[0].getCenterPoint();
return triggerEvent(chart.canvas, null, "click", {
pageX: rectangle.left + point.x,
pageY: rectangle.top + point.y,
});
}
export function getGraphRenderer(graph) {
for (const { component } of Object.values(findChildren(graph).children)) {
if (component.chart) {
return component;
}
}
return null;
}
export function selectMode(el, mode) {
return click(el, `.o_graph_button[data-mode="${mode}"`);
}

View file

@ -0,0 +1,130 @@
/** @odoo-module alias=@web/../tests/views/helpers default=false */
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { getFixture, mount, nextTick } from "@web/../tests/helpers/utils";
import { createDebugContext } from "@web/core/debug/debug_context";
import { Dialog } from "@web/core/dialog/dialog";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { registry } from "@web/core/registry";
import { View, getDefaultConfig } from "@web/views/view";
import {
fakeCompanyService,
makeFakeLocalizationService,
patchUserWithCleanup,
} from "../helpers/mock_services";
import {
setupControlPanelFavoriteMenuRegistry,
setupControlPanelServiceRegistry,
} from "../search/helpers";
import { Component, useSubEnv, xml } from "@odoo/owl";
const serviceRegistry = registry.category("services");
const rootDialogTemplate = xml`<Dialog><View t-props="props.viewProps"/></Dialog>`;
/**
* @typedef {{
* serverData: Object,
* mockRPC?: Function,
* type: string,
* resModel: string,
* [prop:string]: any
* }} MakeViewParams
*/
/**
* @template {Component} T
* @param {MakeViewParams} params
* @param {boolean} [inDialog=false]
* @returns {Promise<T>}
*/
async function _makeView(params, inDialog = false) {
const props = { resId: false, ...params };
const serverData = props.serverData;
const mockRPC = props.mockRPC;
const config = {
...getDefaultConfig(),
...props.config,
};
delete props.serverData;
delete props.mockRPC;
delete props.config;
if (props.arch) {
serverData.views = serverData.views || {};
props.viewId = params.viewId || 100000001; // hopefully will not conflict with an id already in views
serverData.views[`${props.resModel},${props.viewId},${props.type}`] = props.arch;
delete props.arch;
props.searchViewId = 100000002; // hopefully will not conflict with an id already in views
const searchViewArch = props.searchViewArch || "<search/>";
serverData.views[`${props.resModel},${props.searchViewId},search`] = searchViewArch;
delete props.searchViewArch;
}
const env = await makeTestEnv({ serverData, mockRPC });
Object.assign(env, createDebugContext(env)); // This is needed if the views are in debug mode
const target = getFixture();
const viewEnv = Object.assign(Object.create(env), { config });
await mount(MainComponentsContainer, target, { env });
let viewNode;
if (inDialog) {
let root;
class RootDialog extends Component {
static components = { Dialog, View };
static template = rootDialogTemplate;
static props = ["*"];
setup() {
root = this;
useSubEnv(viewEnv);
}
}
env.services.dialog.add(RootDialog, { viewProps: props });
await nextTick();
const rootNode = root.__owl__;
const dialogNode = Object.values(rootNode.children)[0];
viewNode = Object.values(dialogNode.children)[0];
} else {
const view = await mount(View, target, { env: viewEnv, props });
await nextTick();
viewNode = view.__owl__;
}
const withSearchNode = Object.values(viewNode.children)[0];
const concreteViewNode = Object.values(withSearchNode.children)[0];
const concreteView = concreteViewNode.component;
return concreteView;
}
/**
* @param {MakeViewParams} params
*/
export function makeView(params) {
return _makeView(params);
}
/**
* @param {MakeViewParams} params
*/
export function makeViewInDialog(params) {
return _makeView(params, true);
}
export function setupViewRegistries() {
setupControlPanelFavoriteMenuRegistry();
setupControlPanelServiceRegistry();
patchUserWithCleanup({
hasGroup: async (group) => {
return [
"base.group_allow_export",
"base.group_user",
].includes(group);
},
isInternalUser: true,
});
serviceRegistry.add("localization", makeFakeLocalizationService());
serviceRegistry.add("company", fakeCompanyService);
}

View file

@ -0,0 +1,122 @@
/** @odoo-module alias=@web/../tests/views/kanban/helpers default=false */
import { makeFakeDialogService } from "@web/../tests/helpers/mock_services";
import { click, editInput, getDropdownMenu, nextTick } from "@web/../tests/helpers/utils";
import { registry } from "@web/core/registry";
export function patchDialog(addDialog) {
registry.category("services").add("dialog", makeFakeDialogService(addDialog), { force: true });
}
// Kanban
// WOWL remove this helper and use the control panel instead
export async function reload(kanban, params = {}) {
kanban.env.searchModel.reload(params);
kanban.env.searchModel.search();
await nextTick();
}
export function getCard(target, cardIndex = 0) {
return target.querySelectorAll(".o_kanban_record:not(.o_kanban_ghost)")[cardIndex];
}
export function getColumn(target, groupIndex = 0, ignoreFolded = false) {
let selector = ".o_kanban_group";
if (ignoreFolded) {
selector += ":not(.o_column_folded)";
}
return target.querySelectorAll(selector)[groupIndex];
}
export function getColumnDropdownMenu(target, groupIndex = 0, ignoreFolded = false) {
let selector = ".o_kanban_group";
if (ignoreFolded) {
selector += ":not(.o_column_folded)";
}
const column = target.querySelectorAll(selector)[groupIndex];
return getDropdownMenu(target, column);
}
export function getCardTexts(target, groupIndex) {
const root = groupIndex >= 0 ? getColumn(target, groupIndex) : target;
return [...root.querySelectorAll(".o_kanban_record:not(.o_kanban_ghost)")]
.map((card) => card.innerText.trim())
.filter(Boolean);
}
export function getCounters(target) {
return [...target.querySelectorAll(".o_animated_number")].map((counter) => counter.innerText);
}
export function getProgressBars(target, columnIndex) {
const column = getColumn(target, columnIndex);
return [...column.querySelectorAll(".o_column_progress .progress-bar")];
}
export function getTooltips(target, groupIndex) {
const root = groupIndex >= 0 ? getColumn(target, groupIndex) : target;
return [...root.querySelectorAll(".o_column_progress .progress-bar")]
.map((card) => card.dataset.tooltip)
.filter(Boolean);
}
// Record
export async function createRecord(target) {
await click(target, ".o_control_panel_main_buttons button.o-kanban-button-new");
await nextTick();
}
export async function quickCreateRecord(target, groupIndex) {
await click(getColumn(target, groupIndex), ".o_kanban_quick_add");
await nextTick();
}
export async function editQuickCreateInput(target, field, value) {
await editInput(target, `.o_kanban_quick_create .o_field_widget[name=${field}] input`, value);
}
export async function validateRecord(target) {
await click(target, ".o_kanban_quick_create .o_kanban_add");
}
export async function editRecord(target) {
await click(target, ".o_kanban_quick_create .o_kanban_edit");
}
export async function discardRecord(target) {
await click(target, ".o_kanban_quick_create .o_kanban_cancel");
}
export async function toggleRecordDropdown(target, recordIndex) {
const group = target.querySelectorAll(`.o_kanban_record`)[recordIndex];
await click(group, ".o_dropdown_kanban .dropdown-toggle");
}
// Column
export async function createColumn(target) {
await click(target, ".o_column_quick_create > .o_quick_create_folded");
}
export async function editColumnName(target, value) {
await editInput(target, ".o_column_quick_create input", value);
}
export async function validateColumn(target) {
await click(target, ".o_column_quick_create .o_kanban_add");
}
export async function toggleColumnActions(target, columnIndex) {
const group = getColumn(target, columnIndex);
await click(group, ".o_kanban_config .dropdown-toggle");
const buttons = getDropdownMenu(target, group).querySelectorAll(".dropdown-item");
return (buttonText) => {
const re = new RegExp(`\\b${buttonText}\\b`, "i");
const button = [...buttons].find((b) => re.test(b.innerText));
return click(button);
};
}
export async function loadMore(target, columnIndex) {
await click(getColumn(target, columnIndex), ".o_kanban_load_more button");
}

View file

@ -0,0 +1,879 @@
/** @odoo-module alias=@web/../tests/views/list_view_tests default=false */
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { tooltipService } from "@web/core/tooltip/tooltip_service";
import { uiService } from "@web/core/ui/ui_service";
import {
click,
dragAndDrop,
editInput,
getFixture,
getNodesTextContent,
nextTick,
patchWithCleanup,
triggerEvent,
triggerEvents,
triggerHotkey,
} from "../helpers/utils";
import { makeView, setupViewRegistries } from "./helpers";
const serviceRegistry = registry.category("services");
let serverData;
let target;
function getGroup(position) {
return target.querySelectorAll(".o_group_header")[position - 1];
}
QUnit.module("Views", (hooks) => {
hooks.beforeEach(() => {
serverData = {
models: {
foo: {
fields: {
foo: { string: "Foo", type: "char" },
bar: { string: "Bar", type: "boolean" },
date: { string: "Some Date", type: "date" },
int_field: {
string: "int_field",
type: "integer",
sortable: true,
aggregator: "sum",
},
text: { string: "text field", type: "text" },
qux: { string: "my float", type: "float", aggregator: "sum" },
m2o: { string: "M2O field", type: "many2one", relation: "bar" },
o2m: { string: "O2M field", type: "one2many", relation: "bar" },
m2m: { string: "M2M field", type: "many2many", relation: "bar" },
amount: { string: "Monetary field", type: "monetary", aggregator: "sum" },
amount_currency: {
string: "Monetary field (currency)",
type: "monetary",
currency_field: "company_currency_id",
},
currency_id: {
string: "Currency",
type: "many2one",
relation: "res_currency",
default: 1,
},
currency_test: {
string: "Currency",
type: "many2one",
relation: "res_currency",
default: 1,
},
company_currency_id: {
string: "Company Currency",
type: "many2one",
relation: "res_currency",
default: 2,
},
datetime: { string: "Datetime Field", type: "datetime" },
reference: {
string: "Reference Field",
type: "reference",
selection: [
["bar", "Bar"],
["res_currency", "Currency"],
["event", "Event"],
],
},
properties: {
type: "properties",
definition_record: "m2o",
definition_record_field: "definitions",
},
},
records: [
{
id: 1,
bar: true,
foo: "yop",
int_field: 10,
qux: 0.4,
m2o: 1,
m2m: [1, 2],
amount: 1200,
amount_currency: 1100,
currency_id: 2,
company_currency_id: 1,
date: "2017-01-25",
datetime: "2016-12-12 10:55:05",
reference: "bar,1",
properties: [],
},
{
id: 2,
bar: true,
foo: "blip",
int_field: 9,
qux: 13,
m2o: 2,
m2m: [1, 2, 3],
amount: 500,
reference: "res_currency,1",
properties: [],
},
{
id: 3,
bar: true,
foo: "gnap",
int_field: 17,
qux: -3,
m2o: 1,
m2m: [],
amount: 300,
reference: "res_currency,2",
properties: [],
},
{
id: 4,
bar: false,
foo: "blip",
int_field: -4,
qux: 9,
m2o: 1,
m2m: [1],
amount: 0,
properties: [],
},
],
},
bar: {
fields: {
definitions: { type: "properties_definitions" },
},
records: [
{ id: 1, display_name: "Value 1", definitions: [] },
{ id: 2, display_name: "Value 2", definitions: [] },
{ id: 3, display_name: "Value 3", definitions: [] },
],
},
res_currency: {
fields: {
symbol: { string: "Symbol", type: "char" },
position: {
string: "Position",
type: "selection",
selection: [
["after", "A"],
["before", "B"],
],
},
},
records: [
{ id: 1, display_name: "USD", symbol: "$", position: "before" },
{ id: 2, display_name: "EUR", symbol: "€", position: "after" },
],
},
event: {
fields: {
id: { string: "ID", type: "integer" },
name: { string: "name", type: "char" },
},
records: [{ id: "2-20170808020000", name: "virtual" }],
},
},
};
setupViewRegistries();
serviceRegistry.add("tooltip", tooltipService);
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout: () => {},
});
target = getFixture();
serviceRegistry.add("ui", uiService);
});
QUnit.module("ListView");
QUnit.test(
"multi_edit: edit a required field with invalid value and click 'Ok' of alert dialog",
async function (assert) {
serverData.models.foo.fields.foo.required = true;
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list multi_edit="1">
<field name="foo"/>
<field name="int_field"/>
</list>
`,
mockRPC(route, args) {
assert.step(args.method);
},
});
assert.containsN(target, ".o_data_row", 4);
assert.verifySteps(["get_views", "web_search_read"]);
const rows = target.querySelectorAll(".o_data_row");
await click(rows[0], ".o_list_record_selector input");
await click(rows[0].querySelector(".o_data_cell"));
await editInput(target, "[name='foo'] input", "");
await click(target, ".o_list_view");
assert.containsOnce(target, ".modal");
assert.strictEqual(target.querySelector(".modal .btn").textContent, "Ok");
await click(target.querySelector(".modal .btn"));
assert.strictEqual(
target.querySelector(".o_data_row .o_data_cell[name='foo']").textContent,
"yop"
);
assert.hasClass(target.querySelector(".o_data_row"), "o_data_row_selected");
assert.verifySteps([]);
}
);
QUnit.test(
"multi_edit: edit a required field with invalid value and dismiss alert dialog",
async function (assert) {
serverData.models.foo.fields.foo.required = true;
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list multi_edit="1">
<field name="foo"/>
<field name="int_field"/>
</list>`,
mockRPC(route, args) {
assert.step(args.method);
},
});
assert.containsN(target, ".o_data_row", 4);
assert.verifySteps(["get_views", "web_search_read"]);
const rows = target.querySelectorAll(".o_data_row");
await click(rows[0], ".o_list_record_selector input");
await click(rows[0].querySelector(".o_data_cell"));
await editInput(target, "[name='foo'] input", "");
await click(target, ".o_list_view");
assert.containsOnce(target, ".modal");
await click(target.querySelector(".modal-header .btn-close"));
assert.strictEqual(
target.querySelector(".o_data_row .o_data_cell[name='foo']").textContent,
"yop"
);
assert.hasClass(target.querySelector(".o_data_row"), "o_data_row_selected");
assert.verifySteps([]);
}
);
QUnit.test("column widths are re-computed on window resize", async function (assert) {
serverData.models.foo.records[0].text =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. " +
"Sed blandit, justo nec tincidunt feugiat, mi justo suscipit libero, sit amet tempus " +
"ipsum purus bibendum est.";
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list editable="bottom">
<field name="datetime"/>
<field name="text"/>
</list>`,
});
const initialTextWidth = target.querySelectorAll('th[data-name="text"]')[0].offsetWidth;
const selectorWidth = target.querySelectorAll("th.o_list_record_selector")[0].offsetWidth;
// simulate a window resize
target.style.width = target.getBoundingClientRect().width / 2 + "px";
window.dispatchEvent(new Event("resize"));
const postResizeTextWidth = target.querySelectorAll('th[data-name="text"]')[0].offsetWidth;
const postResizeSelectorWidth = target.querySelectorAll("th.o_list_record_selector")[0]
.offsetWidth;
assert.ok(postResizeTextWidth < initialTextWidth);
assert.strictEqual(selectorWidth, postResizeSelectorWidth);
});
QUnit.test(
"editable list view: multi edition error and cancellation handling",
async function (assert) {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list multi_edit="1">
<field name="foo" required="1"/>
<field name="int_field"/>
</list>`,
});
assert.containsN(target, ".o_list_record_selector input:enabled", 5);
// select two records
const rows = target.querySelectorAll(".o_data_row");
await click(rows[0], ".o_list_record_selector input");
await click(rows[1], ".o_list_record_selector input");
// edit a line and cancel
await click(rows[0].querySelector(".o_data_cell"));
assert.containsNone(target, ".o_list_record_selector input:enabled");
await editInput(target, ".o_selected_row [name=foo] input", "abc");
await click(target, ".modal .btn.btn-secondary");
assert.strictEqual(
$(target).find(".o_data_row:eq(0) .o_data_cell").text(),
"yop10",
"first cell should have discarded any change"
);
assert.containsN(target, ".o_list_record_selector input:enabled", 5);
// edit a line with an invalid format type
await click(rows[0].querySelectorAll(".o_data_cell")[1]);
assert.containsNone(target, ".o_list_record_selector input:enabled");
await editInput(target, ".o_selected_row [name=int_field] input", "hahaha");
assert.containsOnce(target, ".modal", "there should be an opened modal");
await click(target, ".modal .btn-primary");
assert.strictEqual(
$(target).find(".o_data_row:eq(0) .o_data_cell").text(),
"yop10",
"changes should be discarded"
);
assert.containsN(target, ".o_list_record_selector input:enabled", 5);
// edit a line with an invalid value
await click(rows[0].querySelector(".o_data_cell"));
assert.containsNone(target, ".o_list_record_selector input:enabled");
await editInput(target, ".o_selected_row [name=foo] input", "");
assert.containsOnce(target, ".modal", "there should be an opened modal");
await click(target, ".modal .btn-primary");
assert.strictEqual(
$(target).find(".o_data_row:eq(0) .o_data_cell").text(),
"yop10",
"changes should be discarded"
);
assert.containsN(target, ".o_list_record_selector input:enabled", 5);
}
);
QUnit.test(
'editable list view: mousedown on "Discard", mouseup somewhere else (no multi-edit)',
async function (assert) {
await makeView({
type: "list",
arch: `
<list editable="top">
<field name="foo"/>
</list>`,
mockRPC(route, args) {
assert.step(args.method);
},
serverData,
resModel: "foo",
});
// select two records
const rows = target.querySelectorAll(".o_data_row");
await click(rows[0], ".o_list_record_selector input");
await click(rows[1], ".o_list_record_selector input");
await click(rows[0].querySelector(".o_data_cell"));
target.querySelector(".o_data_row .o_data_cell input").value = "oof";
await triggerEvents($(".o_list_button_discard:visible").get(0), null, ["mousedown"]);
await triggerEvents(target, ".o_data_row .o_data_cell input", [
"change",
"blur",
"focusout",
]);
await triggerEvents(target, null, ["focus"]);
await triggerEvents(target, null, ["mouseup"]);
await click(target);
assert.containsNone(document.body, ".modal", "should not open modal");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell")), [
"oof",
"blip",
"gnap",
"blip",
]);
assert.verifySteps(["get_views", "web_search_read", "web_save"]);
}
);
QUnit.test(
"editable readonly list view: single edition does not behave like a multi-edition",
async function (assert) {
await makeView({
type: "list",
arch: `
<list multi_edit="1">
<field name="foo" required="1"/>
</list>`,
serverData,
resModel: "foo",
});
// select a record
const rows = target.querySelectorAll(".o_data_row");
await click(rows[0], ".o_list_record_selector input");
// edit a field (invalid input)
await click(rows[0].querySelector(".o_data_cell"));
await editInput(target, ".o_data_row [name=foo] input", "");
assert.containsOnce(target, ".modal", "should have a modal (invalid fields)");
await click(target, ".modal button.btn");
// edit a field
await click(rows[0].querySelector(".o_data_cell"));
await editInput(target, ".o_data_row [name=foo] input", "bar");
assert.containsNone(target, ".modal", "should not have a modal");
assert.strictEqual(
$(target).find(".o_data_row:eq(0) .o_data_cell").text(),
"bar",
"the first row should be updated"
);
}
);
QUnit.test(
"pressing ESC in editable grouped list should discard the current line changes",
async function (assert) {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: '<list editable="top"><field name="foo"/><field name="bar"/></list>',
groupBy: ["bar"],
});
await click(target.querySelectorAll(".o_group_header")[1]); // open second group
assert.containsN(target, "tr.o_data_row", 3);
await click(target.querySelector(".o_data_cell"));
// update foo field of edited row
await editInput(target, ".o_data_cell [name=foo] input", "new_value");
assert.strictEqual(
document.activeElement,
target.querySelector(".o_data_cell [name=foo] input")
);
// discard by pressing ESC
triggerHotkey("Escape");
await nextTick();
assert.containsNone(target, ".modal");
assert.containsOnce(target, "tbody tr td:contains(yop)");
assert.containsN(target, "tr.o_data_row", 3);
assert.containsNone(target, "tr.o_data_row.o_selected_row");
assert.isNotVisible(target.querySelector(".o_list_button_save"));
}
);
QUnit.test("editing then pressing TAB in editable grouped list", async function (assert) {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: '<list editable="bottom"><field name="foo"/></list>',
mockRPC(route, args) {
assert.step(args.method || route);
},
groupBy: ["bar"],
});
// open two groups
await click(getGroup(1));
assert.containsN(target, ".o_data_row", 1, "first group contains 1 rows");
await click(getGroup(2));
assert.containsN(target, ".o_data_row", 4, "first group contains 3 row");
// select and edit last row of first group
await click(target.querySelector(".o_data_row").querySelector(".o_data_cell"));
assert.hasClass($(target).find(".o_data_row:nth(0)"), "o_selected_row");
await editInput(target, '.o_selected_row [name="foo"] input', "new value");
// Press 'Tab' -> should create a new record as we edited the previous one
triggerHotkey("Tab");
await nextTick();
assert.containsN(target, ".o_data_row", 5);
assert.hasClass($(target).find(".o_data_row:nth(1)"), "o_selected_row");
// fill foo field for the new record and press 'tab' -> should create another record
await editInput(target, '.o_selected_row [name="foo"] input', "new record");
triggerHotkey("Tab");
await nextTick();
assert.containsN(target, ".o_data_row", 6);
assert.hasClass($(target).find(".o_data_row:nth(2)"), "o_selected_row");
// leave this new row empty and press tab -> should discard the new record and move to the
// next group
triggerHotkey("Tab");
await nextTick();
assert.containsN(target, ".o_data_row", 5);
assert.hasClass($(target).find(".o_data_row:nth(2)"), "o_selected_row");
assert.verifySteps([
"get_views",
"web_read_group",
"web_search_read",
"web_search_read",
"web_save",
"onchange",
"web_save",
"onchange",
]);
});
QUnit.test("cell-level keyboard navigation in editable grouped list", async function (assert) {
serverData.models.foo.records[0].bar = false;
serverData.models.foo.records[1].bar = false;
serverData.models.foo.records[2].bar = false;
serverData.models.foo.records[3].bar = true;
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list editable="bottom">
<field name="foo" required="1"/>
</list>`,
groupBy: ["bar"],
});
await click(target.querySelector(".o_group_name"));
const secondDataRow = target.querySelectorAll(".o_data_row")[1];
await click(secondDataRow, "[name=foo]");
assert.hasClass(secondDataRow, "o_selected_row");
await editInput(secondDataRow, "[name=foo] input", "blipbloup");
triggerHotkey("Escape");
await nextTick();
assert.containsNone(document.body, ".modal");
assert.doesNotHaveClass(secondDataRow, "o_selected_row");
assert.strictEqual(document.activeElement, secondDataRow.querySelector("[name=foo]"));
assert.strictEqual(document.activeElement.textContent, "blip");
triggerHotkey("ArrowLeft");
assert.strictEqual(
document.activeElement,
secondDataRow.querySelector("input[type=checkbox]")
);
triggerHotkey("ArrowUp");
triggerHotkey("ArrowRight");
const firstDataRow = target.querySelector(".o_data_row");
assert.strictEqual(document.activeElement, firstDataRow.querySelector("[name=foo]"));
triggerHotkey("Enter");
await nextTick();
assert.hasClass(firstDataRow, "o_selected_row");
await editInput(firstDataRow, "[name=foo] input", "Zipadeedoodah");
triggerHotkey("Enter");
await nextTick();
assert.strictEqual(firstDataRow.querySelector("[name=foo]").innerText, "Zipadeedoodah");
assert.doesNotHaveClass(firstDataRow, "o_selected_row");
assert.hasClass(secondDataRow, "o_selected_row");
assert.strictEqual(document.activeElement, secondDataRow.querySelector("[name=foo] input"));
assert.strictEqual(document.activeElement.value, "blip");
triggerHotkey("ArrowUp");
triggerHotkey("ArrowRight");
await nextTick();
assert.strictEqual(document.activeElement, secondDataRow.querySelector("[name=foo] input"));
assert.strictEqual(document.activeElement.value, "blip");
triggerHotkey("ArrowDown");
triggerHotkey("ArrowLeft");
await nextTick();
assert.strictEqual(
document.activeElement,
secondDataRow.querySelector("td[name=foo] input")
);
assert.strictEqual(document.activeElement.value, "blip");
triggerHotkey("Escape");
await nextTick();
assert.doesNotHaveClass(secondDataRow, "o_selected_row");
assert.strictEqual(document.activeElement, secondDataRow.querySelector("td[name=foo]"));
triggerHotkey("ArrowDown");
triggerHotkey("ArrowDown");
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_field_row_add a")
);
triggerHotkey("ArrowDown");
const secondGroupHeader = target.querySelectorAll(".o_group_name")[1];
assert.strictEqual(document.activeElement, secondGroupHeader);
assert.containsN(target, ".o_data_row", 3);
triggerHotkey("Enter");
await nextTick();
assert.containsN(target, ".o_data_row", 4);
assert.strictEqual(document.activeElement, secondGroupHeader);
triggerHotkey("ArrowDown");
const fourthDataRow = target.querySelectorAll(".o_data_row")[3];
assert.strictEqual(document.activeElement, fourthDataRow.querySelector("[name=foo]"));
triggerHotkey("ArrowDown");
assert.strictEqual(
document.activeElement,
target.querySelectorAll(".o_group_field_row_add a")[1]
);
triggerHotkey("ArrowDown");
assert.strictEqual(
document.activeElement,
target.querySelectorAll(".o_group_field_row_add a")[1]
);
// default Enter on a A tag
const event = await triggerEvent(document.activeElement, null, "keydown", { key: "Enter" });
assert.ok(!event.defaultPrevented);
await click(target.querySelectorAll(".o_group_field_row_add a")[1]);
const fifthDataRow = target.querySelectorAll(".o_data_row")[4];
assert.strictEqual(document.activeElement, fifthDataRow.querySelector("[name=foo] input"));
await editInput(
fifthDataRow.querySelector("[name=foo] input"),
null,
"cheateur arrete de cheater"
);
triggerHotkey("Enter");
await nextTick();
assert.containsN(target, ".o_data_row", 6);
triggerHotkey("Escape");
await nextTick();
assert.strictEqual(
document.activeElement,
target.querySelectorAll(".o_group_field_row_add a")[1]
);
// come back to the top
for (let i = 0; i < 9; i++) {
triggerHotkey("ArrowUp");
}
assert.strictEqual(document.activeElement, target.querySelector("thead th:nth-child(2)"));
triggerHotkey("ArrowLeft");
assert.strictEqual(
document.activeElement,
target.querySelector("thead th.o_list_record_selector input")
);
triggerHotkey("ArrowDown");
triggerHotkey("ArrowDown");
triggerHotkey("ArrowRight");
assert.strictEqual(document.activeElement, firstDataRow.querySelector("td[name=foo]"));
triggerHotkey("ArrowUp");
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
assert.containsN(target, ".o_data_row", 5);
triggerHotkey("Enter");
await nextTick();
assert.containsN(target, ".o_data_row", 2);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
triggerHotkey("ArrowRight");
await nextTick();
assert.containsN(target, ".o_data_row", 5);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
triggerHotkey("ArrowRight");
await nextTick();
assert.containsN(target, ".o_data_row", 5);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
triggerHotkey("ArrowLeft");
await nextTick();
assert.containsN(target, ".o_data_row", 2);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
triggerHotkey("ArrowLeft");
await nextTick();
assert.containsN(target, ".o_data_row", 2);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
triggerHotkey("ArrowDown");
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(2) .o_group_name")
);
triggerHotkey("ArrowDown");
const firstVisibleDataRow = target.querySelector(".o_data_row");
assert.strictEqual(document.activeElement, firstVisibleDataRow.querySelector("[name=foo]"));
triggerHotkey("ArrowDown");
const secondVisibleDataRow = target.querySelectorAll(".o_data_row")[1];
assert.strictEqual(
document.activeElement,
secondVisibleDataRow.querySelector("[name=foo]")
);
triggerHotkey("ArrowDown");
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_field_row_add a")
);
triggerHotkey("ArrowUp");
assert.strictEqual(
document.activeElement,
secondVisibleDataRow.querySelector("[name=foo]")
);
triggerHotkey("ArrowUp");
assert.strictEqual(document.activeElement, firstVisibleDataRow.querySelector("[name=foo]"));
});
QUnit.test("editable list: resize column headers", async function (assert) {
// This test will ensure that, on resize list header,
// the resized element have the correct size and other elements are not resized
serverData.models.foo.records[0].foo = "a".repeat(200);
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list editable="top">
<field name="foo"/>
<field name="bar"/>
<field name="reference" optional="hide"/>
</list>`,
});
// Target handle
const th = target.querySelector("th:nth-child(2)");
const thNext = target.querySelector("th:nth-child(3)");
const resizeHandle = th.querySelector(".o_resize");
const nextResizeHandle = thNext.querySelector(".o_resize");
const thOriginalWidth = th.getBoundingClientRect().width;
const thNextOriginalWidth = thNext.getBoundingClientRect().width;
const thExpectedWidth = thOriginalWidth + thNextOriginalWidth;
await dragAndDrop(resizeHandle, nextResizeHandle);
const thFinalWidth = th.getBoundingClientRect().width;
const thNextFinalWidth = thNext.getBoundingClientRect().width;
assert.ok(
Math.abs(Math.floor(thFinalWidth) - Math.floor(thExpectedWidth)) <= 1,
`Wrong width on resize (final: ${thFinalWidth}, expected: ${thExpectedWidth})`
);
assert.strictEqual(
Math.floor(thNextOriginalWidth),
Math.floor(thNextFinalWidth),
"Width must not have been changed"
);
});
QUnit.test(
"continue creating new lines in editable=top on keyboard nav",
async function (assert) {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list editable="top">
<field name="int_field"/>
</list>`,
});
const initialRowCount = $(".o_data_cell[name=int_field]").length;
// click on int_field cell of first row
await click($(".o_list_button_add:visible").get(0));
await editInput(target, ".o_data_cell[name=int_field] input", "1");
triggerHotkey("Tab");
await nextTick();
await editInput(target, ".o_data_cell[name=int_field] input", "2");
triggerHotkey("Enter");
await nextTick();
// 3 new rows: the two created ("1" and "2", and a new still in edit mode)
assert.strictEqual($(".o_data_cell[name=int_field]").length, initialRowCount + 3);
}
);
});

View file

@ -0,0 +1,431 @@
/** @odoo-module alias=@web/../tests/webclient/helpers default=false */
import { dialogService } from "@web/core/dialog/dialog_service";
import { notificationService } from "@web/core/notifications/notification_service";
import { ormService } from "@web/core/orm_service";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { viewService } from "@web/views/view_service";
import { actionService } from "@web/webclient/actions/action_service";
import { effectService } from "@web/core/effects/effect_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { menuService } from "@web/webclient/menus/menu_service";
import { WebClient } from "@web/webclient/webclient";
import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import {
fakeTitleService,
fakeCompanyService,
makeFakePwaService,
makeFakeLocalizationService,
makeFakeHTTPService,
makeFakeBarcodeService,
} from "../helpers/mock_services";
import { getFixture, mount, nextTick } from "../helpers/utils";
import { uiService } from "@web/core/ui/ui_service";
import { commandService } from "@web/core/commands/command_service";
import { CustomFavoriteItem } from "@web/search/custom_favorite_item/custom_favorite_item";
import { overlayService } from "@web/core/overlay/overlay_service";
import { Component, onMounted, xml } from "@odoo/owl";
import { fieldService } from "@web/core/field_service";
import { nameService } from "@web/core/name_service";
import { datetimePickerService } from "@web/core/datetime/datetimepicker_service";
const actionRegistry = registry.category("actions");
const serviceRegistry = registry.category("services");
const favoriteMenuRegistry = registry.category("favoriteMenu");
/**
* Builds the required registries for tests using a WebClient.
* We use a default version of each required registry item.
* If the registry already contains one of those items,
* the existing one is kept (it means it has been added in the test
* directly, e.g. to have a custom version of the item).
*/
export function setupWebClientRegistries() {
const favoriveMenuItems = {
"custom-favorite-item": {
value: { Component: CustomFavoriteItem, groupNumber: 3 },
options: { sequence: 0 },
},
};
for (const [key, { value, options }] of Object.entries(favoriveMenuItems)) {
if (!favoriteMenuRegistry.contains(key)) {
favoriteMenuRegistry.add(key, value, options);
}
}
const services = {
action: () => actionService,
barcode: () => makeFakeBarcodeService(),
command: () => commandService,
dialog: () => dialogService,
effect: () => effectService,
field: () => fieldService,
hotkey: () => hotkeyService,
http: () => makeFakeHTTPService(),
pwa: () => makeFakePwaService(),
localization: () => makeFakeLocalizationService(),
menu: () => menuService,
name: () => nameService,
notification: () => notificationService,
orm: () => ormService,
overlay: () => overlayService,
popover: () => popoverService,
title: () => fakeTitleService,
ui: () => uiService,
view: () => viewService,
company: () => fakeCompanyService,
datetime_picker: () => datetimePickerService,
};
for (const serviceName in services) {
if (!serviceRegistry.contains(serviceName)) {
serviceRegistry.add(serviceName, services[serviceName]());
}
}
}
/**
* This method create a web client instance properly configured.
*
* Note that the returned web client will be automatically cleaned up after the
* end of the test.
*
* @param {*} params
*/
export async function createWebClient(params) {
setupWebClientRegistries();
params.serverData = params.serverData || {};
const mockRPC = params.mockRPC || undefined;
const env = await makeTestEnv({
serverData: params.serverData,
mockRPC,
});
const WebClientClass = params.WebClientClass || WebClient;
const target = params && params.target ? params.target : getFixture();
const wc = await mount(WebClientClass, target, { env });
odoo.__WOWL_DEBUG__ = { root: wc };
target.classList.add("o_web_client"); // necessary for the stylesheet
registerCleanup(() => {
target.classList.remove("o_web_client");
});
// Wait for visual changes caused by a potential loadState
await nextTick();
// wait for BlankComponent
await nextTick();
// wait for the regular rendering
await nextTick();
return wc;
}
export function doAction(env, ...args) {
if (env instanceof Component) {
env = env.env;
}
return env.services.action.doAction(...args);
}
export function getActionManagerServerData() {
// additional basic client action
class TestClientAction extends Component {
static template = xml`
<div class="test_client_action">
ClientAction_<t t-esc="props.action.params?.description"/>
</div>`;
static props = ["*"];
setup() {
onMounted(() =>
this.env.config.setDisplayName(`Client action ${this.props.action.id}`)
);
}
}
actionRegistry.add("__test__client__action__", TestClientAction);
const menus = {
root: { id: "root", children: [0, 1, 2], name: "root", appID: "root" },
// id:0 is a hack to not load anything at webClient mount
0: { id: 0, children: [], name: "UglyHack", appID: 0, xmlid: "menu_0" },
1: { id: 1, children: [], name: "App1", appID: 1, actionID: 1001, xmlid: "menu_1" },
2: { id: 2, children: [], name: "App2", appID: 2, actionID: 1002, xmlid: "menu_2" },
};
const actionsArray = [
{
id: 1,
xml_id: "action_1",
name: "Partners Action 1",
res_model: "partner",
type: "ir.actions.act_window",
views: [[1, "kanban"]],
},
{
id: 2,
xml_id: "action_2",
type: "ir.actions.server",
},
{
id: 3,
xml_id: "action_3",
name: "Partners",
res_model: "partner",
mobile_view_mode: "kanban",
type: "ir.actions.act_window",
views: [
[false, "list"],
[1, "kanban"],
[false, "form"],
],
},
{
id: 4,
xml_id: "action_4",
name: "Partners Action 4",
res_model: "partner",
type: "ir.actions.act_window",
views: [
[1, "kanban"],
[2, "list"],
[false, "form"],
],
},
{
id: 5,
xml_id: "action_5",
name: "Create a Partner",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[false, "form"]],
},
{
id: 6,
xml_id: "action_6",
name: "Partner",
res_id: 2,
res_model: "partner",
target: "inline",
type: "ir.actions.act_window",
views: [[false, "form"]],
},
{
id: 7,
xml_id: "action_7",
name: "Some Report",
report_name: "some_report",
report_type: "qweb-pdf",
type: "ir.actions.report",
},
{
id: 8,
xml_id: "action_8",
name: "Favorite Ponies",
res_model: "pony",
type: "ir.actions.act_window",
views: [
[false, "list"],
[false, "form"],
],
},
{
id: 9,
xml_id: "action_9",
name: "A Client Action",
tag: "ClientAction",
type: "ir.actions.client",
},
{
id: 10,
type: "ir.actions.act_window_close",
},
{
id: 11,
xml_id: "action_11",
name: "Another Report",
report_name: "another_report",
report_type: "qweb-pdf",
type: "ir.actions.report",
close_on_report_download: true,
},
{
id: 12,
xml_id: "action_12",
name: "Some HTML Report",
report_name: "some_report",
report_type: "qweb-html",
type: "ir.actions.report",
},
{
id: 24,
name: "Partner",
res_id: 2,
res_model: "partner",
type: "ir.actions.act_window",
views: [[666, "form"]],
},
{
id: 25,
name: "Create a Partner",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[3, "form"]],
},
{
id: 26,
xml_id: "action_26",
name: "Partner",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[false, "list"]],
},
{
id: 27,
xml_id: "action_27",
name: "Partners Action 27",
res_model: "partner",
mobile_view_mode: "kanban",
type: "ir.actions.act_window",
path: "partners",
views: [
[false, "list"],
[1, "kanban"],
[false, "form"],
],
},
{
id: 28,
xml_id: "action_28",
name: "Partners Action 28",
res_model: "partner",
type: "ir.actions.act_window",
views: [
[1, "kanban"],
[2, "list"],
[false, "form"],
],
},
{
id: 1001,
tag: "__test__client__action__",
target: "main",
type: "ir.actions.client",
params: { description: "Id 1" },
},
{
id: 1002,
tag: "__test__client__action__",
target: "main",
type: "ir.actions.client",
params: { description: "Id 2" },
},
{
xmlId: "wowl.client_action",
id: 1099,
tag: "__test__client__action__",
target: "main",
type: "ir.actions.client",
params: { description: "xmlId" },
},
];
const actions = {};
actionsArray.forEach((act) => {
actions[act.xmlId || act.id] = act;
});
const archs = {
// kanban views
"partner,1,kanban":
'<kanban><templates><t t-name="card">' +
'<field name="foo"/>' +
"</t></templates></kanban>",
// list views
"partner,false,list": '<list><field name="foo"/></list>',
"partner,2,list": '<list limit="3"><field name="foo"/></list>',
"pony,false,list": '<list><field name="name"/></list>',
// form views
"partner,false,form":
"<form>" +
"<header>" +
'<button name="object" string="Call method" type="object"/>' +
'<button name="4" string="Execute action" type="action"/>' +
"</header>" +
"<group>" +
'<field name="display_name"/>' +
'<field name="foo"/>' +
"</group>" +
"</form>",
"partner,3,form": `
<form>
<footer>
<button class="btn-primary" string="Save" special="save"/>
</footer>
</form>`,
"partner,666,form": `<form>
<header></header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" type="action" name="1" icon="fa-star" context="{'default_partner': id}">
<field string="Partners" name="o2m" widget="statinfo"/>
</button>
</div>
<field name="display_name"/>
</sheet>
</form>`,
"pony,false,form": "<form>" + '<field name="name"/>' + "</form>",
// search views
"partner,false,search": '<search><field name="foo" string="Foo"/></search>',
"partner,4,search":
"<search>" +
'<filter name="bar" help="Bar" domain="[(\'bar\', \'=\', 1)]"/>' +
"</search>",
"pony,false,search": "<search></search>",
};
const models = {
partner: {
fields: {
id: { string: "Id", type: "integer" },
foo: { string: "Foo", type: "char" },
bar: { string: "Bar", type: "many2one", relation: "partner" },
o2m: {
string: "One2Many",
type: "one2many",
relation: "partner",
relation_field: "bar",
},
m2o: { string: "Many2one", type: "many2one", relation: "partner" },
},
records: [
{ id: 1, display_name: "First record", foo: "yop", bar: 2, o2m: [2, 3], m2o: 3 },
{
id: 2,
display_name: "Second record",
foo: "blip",
bar: 1,
o2m: [1, 4, 5],
m2o: 3,
},
{ id: 3, display_name: "Third record", foo: "gnap", bar: 1, o2m: [], m2o: 1 },
{ id: 4, display_name: "Fourth record", foo: "plop", bar: 2, o2m: [], m2o: 1 },
{ id: 5, display_name: "Fifth record", foo: "zoup", bar: 2, o2m: [], m2o: 1 },
],
},
pony: {
fields: {
id: { string: "Id", type: "integer" },
name: { string: "Name", type: "char" },
},
records: [
{ id: 4, name: "Twilight Sparkle" },
{ id: 6, name: "Applejack" },
{ id: 9, name: "Fluttershy" },
],
},
};
return {
models,
views: archs,
actions,
menus,
};
}