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,12 @@
import { expect } from "@odoo/hoot";
/**
* Use `expect.step` instead
* @deprecated
*/
export const asyncStep = expect.step;
/**
* Use `expect.waitForSteps` instead
* @deprecated
*/
export const waitForSteps = expect.waitForSteps;

View file

@ -0,0 +1,178 @@
import { after, destroy, getFixture } from "@odoo/hoot";
import { queryFirst, queryOne } from "@odoo/hoot-dom";
import { App, Component, xml } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { getPopoverForTarget } from "@web/core/popover/popover";
import { getTemplate as getTemplateFn } from "@web/core/templates";
import { isIterable } from "@web/core/utils/arrays";
import { patch } from "@web/core/utils/patch";
import { getMockEnv, makeMockEnv } from "./env_test_helpers";
/**
* @typedef {import("@odoo/hoot-dom").Target} Target
* @typedef {import("@odoo/owl").Component} Component
* @typedef {import("@web/env").OdooEnv} OdooEnv
*
* @typedef {ConstructorParameters<typeof App>[1]} AppConfig
*/
/**
* @template [P=any]
* @template [E=any]
* @typedef {import("@odoo/owl").ComponentConstructor<P, E>} ComponentConstructor
*/
/**
* @param {ComponentConstructor} ComponentClass
* @param {HTMLElement | ShadowRoot} targetEl
* @param {AppConfig} config
*/
const mountComponentWithCleanup = (ComponentClass, targetEl, config) => {
const app = new App(ComponentClass, config);
after(() => destroy(app));
return app.mount(targetEl);
};
patch(MainComponentsContainer.prototype, {
setup() {
super.setup();
hasMainComponent = true;
after(() => (hasMainComponent = false));
},
});
let hasMainComponent = false;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {App | Component} parent
* @param {(component: Component) => boolean} predicate
* @returns {Component | null}
*/
export function findComponent(parent, predicate) {
const rootNode = parent instanceof App ? parent.root : parent.__owl__;
const queue = [rootNode, ...Object.values(rootNode.children)];
while (queue.length) {
const { children, component } = queue.pop();
if (predicate(component)) {
return component;
}
queue.unshift(...Object.values(children));
}
return null;
}
/**
* Returns the dropdown menu for a specific toggler.
*
* @param {Target} togglerSelector
* @returns {HTMLElement | undefined}
*/
export function getDropdownMenu(togglerSelector) {
let el = queryFirst(togglerSelector);
if (el && !el.classList.contains("o-dropdown")) {
el = el.querySelector(".o-dropdown");
}
if (!el) {
throw new Error(`getDropdownMenu: Could not find element "${togglerSelector}".`);
}
return getPopoverForTarget(el);
}
/**
* Mounts a given component to the test fixture.
*
* By default, a `MainComponentsContainer` component is also mounted to the
* fixture if none is found in the component tree (this can be overridden by the
* `noMainContainer` option).
*
* @template {ComponentConstructor<P, E>} C
* @template [P={}]
* @template [E=OdooEnv]
* @param {C | string} ComponentClass
* @param {AppConfig & {
* componentEnv?: Partial<OdooEnv>;
* containerEnv?: Partial<OdooEnv>;
* fixtureClassName?: string | string[] | null;
* env?: E;
* noMainContainer?: boolean;
* props?: P;
* target?: Target;
* }} [options]
*/
export async function mountWithCleanup(ComponentClass, options) {
const {
componentEnv,
containerEnv,
customDirectives,
env,
fixtureClassName = "o_web_client",
getTemplate = getTemplateFn,
globalValues,
noMainContainer,
props,
target,
templates,
translatableAttributes,
translateFn = _t,
} = options || {};
// Common component configuration
const commonConfig = {
customDirectives,
getTemplate,
globalValues,
templates,
translatableAttributes,
translateFn,
// The following keys are forced to ensure validation of all tested components
dev: false,
test: true,
warnIfNoStaticProps: true,
};
// Fixture
const fixture = getFixture();
const targetEl = target ? queryOne(target) : fixture;
if (fixtureClassName) {
const list = isIterable(fixtureClassName) ? fixtureClassName : [fixtureClassName];
fixture.classList.add(...list);
}
if (typeof ComponentClass === "string") {
// Convert templates to components (if needed)
ComponentClass = class extends Component {
static name = "anonymous component";
static props = {};
static template = xml`${ComponentClass}`;
};
}
const commonEnv = env || getMockEnv() || (await makeMockEnv());
const componentConfig = {
...commonConfig,
env: Object.assign(Object.create(commonEnv), componentEnv),
name: `TEST: ${ComponentClass.name}`,
props,
};
/** @type {InstanceType<C>} */
const component = await mountComponentWithCleanup(ComponentClass, targetEl, componentConfig);
if (!noMainContainer && !hasMainComponent) {
const containerConfig = {
...commonConfig,
env: Object.assign(Object.create(commonEnv), containerEnv),
name: `TEST: ${ComponentClass.name} (main container)`,
props: {},
};
await mountComponentWithCleanup(MainComponentsContainer, targetEl, containerConfig);
}
return component;
}

View file

@ -0,0 +1,381 @@
import { after, afterEach } from "@odoo/hoot";
import {
check,
clear,
click,
dblclick,
drag,
edit,
fill,
getActiveElement,
hover,
keyDown,
keyUp,
manuallyDispatchProgrammaticEvent,
pointerDown,
press,
queryOne,
scroll,
select,
uncheck,
waitFor,
} from "@odoo/hoot-dom";
import { advanceFrame, advanceTime, animationFrame } from "@odoo/hoot-mock";
import { hasTouch } from "@web/core/browser/feature_detection";
/**
* @typedef {import("@odoo/hoot-dom").DragHelpers} DragHelpers
* @typedef {import("@odoo/hoot-dom").DragOptions} DragOptions
* @typedef {import("@odoo/hoot-dom").FillOptions} FillOptions
* @typedef {import("@odoo/hoot-dom").InputValue} InputValue
* @typedef {import("@odoo/hoot-dom").KeyStrokes} KeyStrokes
* @typedef {import("@odoo/hoot-dom").PointerOptions} PointerOptions
* @typedef {import("@odoo/hoot-dom").Position} Position
* @typedef {import("@odoo/hoot-dom").QueryOptions} QueryOptions
* @typedef {import("@odoo/hoot-dom").Target} Target
*
* @typedef {DragOptions & {
* initialPointerMoveDistance?: number;
* pointerDownDuration: number;
* }} DragAndDropOptions
*
* @typedef {{
* altKey?: boolean;
* ctrlKey?: boolean;
* metaKey?: boolean;
* shiftKey?: boolean;
* }} KeyModifierOptions
*/
/**
* @template T
* @typedef {import("@odoo/hoot-dom").MaybePromise<T>} MaybePromise
*/
/**
* @template T
* @typedef {(...args: Parameters<T>) => MaybePromise<ReturnType<T>>} Promisify
*/
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {typeof click} clickFn
* @param {Promise<Element>} nodePromise
* @param {PointerOptions & KeyModifierOptions} [options]
*/
const callClick = async (clickFn, nodePromise, options) => {
const actions = [() => clickFn(nodePromise, options)];
if (options?.altKey) {
actions.unshift(() => keyDown("Alt"));
actions.push(() => keyUp("Alt"));
}
if (options?.ctrlKey) {
actions.unshift(() => keyDown("Control"));
actions.push(() => keyUp("Control"));
}
if (options?.metaKey) {
actions.unshift(() => keyDown("Meta"));
actions.push(() => keyUp("Meta"));
}
if (options?.shiftKey) {
actions.unshift(() => keyDown("Shift"));
actions.push(() => keyUp("Shift"));
}
for (const action of actions) {
await action();
}
await animationFrame();
};
/**
* @param {Node} node
* @param {number} [distance]
*/
const dragForTolerance = async (node, distance) => {
if (distance === 0) {
return;
}
const position = {
x: distance || 100,
y: distance || 100,
};
await hover(node, { position, relative: true });
await advanceFrame();
};
/**
* @param {number} [delay]
* These params are used to move the pointer from an arbitrary distance in the
* element to trigger a drag sequence (the distance required to trigger a drag
* is defined by the `tolerance` option in the draggable hook builder).
* @see {draggable_hook_builder.js}
*/
const waitForTouchDelay = async (delay) => {
if (hasTouch()) {
await advanceTime(delay || 500);
}
};
let unconsumedContains = [];
afterEach(() => {
if (unconsumedContains.length) {
const targets = unconsumedContains.map(String).join(", ");
unconsumedContains = [];
throw new Error(
`called 'contains' on "${targets}" without any action: use 'waitFor' if no interaction is intended`
);
}
});
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {Target} target
* @param {QueryOptions} [options]
*/
export function contains(target, options) {
const consumeContains = () => {
if (!consumed) {
consumed = true;
unconsumedContains.pop();
}
};
const focusCurrent = async () => {
const node = await nodePromise;
if (node !== getActiveElement(node)) {
await pointerDown(node);
}
return node;
};
let consumed = false;
unconsumedContains.push(target);
/** @type {Promise<Element>} */
const nodePromise = waitFor.as("contains")(target, { visible: true, ...options });
return {
/**
* @param {PointerOptions} [options]
*/
check: async (options) => {
consumeContains();
await check(nodePromise, options);
await animationFrame();
},
/**
* @param {FillOptions} [options]
*/
clear: async (options) => {
consumeContains();
await focusCurrent();
await clear({ confirm: "auto", ...options });
await animationFrame();
},
/**
* @param {PointerOptions & KeyModifierOptions} [options]
*/
click: async (options) => {
consumeContains();
await callClick(click, nodePromise, options);
},
/**
* @param {PointerOptions & KeyModifierOptions} [options]
*/
dblclick: async (options) => {
consumeContains();
await callClick(dblclick, nodePromise, options);
},
/**
* @param {DragAndDropOptions} [options]
* @returns {Promise<DragHelpers>}
*/
drag: async (options) => {
consumeContains();
/** @type {typeof cancel} */
const cancelWithDelay = async (options) => {
await cancel(options);
await advanceFrame();
};
/** @type {typeof drop} */
const dropWithDelay = async (to, options) => {
if (to) {
await moveToWithDelay(to, options);
}
await drop();
await advanceFrame();
};
/** @type {typeof moveTo} */
const moveToWithDelay = async (to, options) => {
await moveTo(to, options);
await advanceFrame();
return helpersWithDelay;
};
const { cancel, drop, moveTo } = await drag(nodePromise, options);
const helpersWithDelay = {
cancel: cancelWithDelay,
drop: dropWithDelay,
moveTo: moveToWithDelay,
};
await waitForTouchDelay(options?.pointerDownDuration);
await dragForTolerance(nodePromise, options?.initialPointerMoveDistance);
return helpersWithDelay;
},
/**
* @param {Target} target
* @param {DragAndDropOptions} [dropOptions]
* @param {DragOptions} [dragOptions]
*/
dragAndDrop: async (target, dropOptions, dragOptions) => {
consumeContains();
const [from, to] = await Promise.all([nodePromise, waitFor(target)]);
const { drop, moveTo } = await drag(from, dragOptions);
await waitForTouchDelay(dropOptions?.pointerDownDuration);
await dragForTolerance(from, dropOptions?.initialPointerMoveDistance);
await moveTo(to, dropOptions);
await advanceFrame();
await drop();
await advanceFrame();
},
/**
* @param {InputValue} value
* @param {FillOptions} [options]
*/
edit: async (value, options) => {
consumeContains();
await focusCurrent();
await edit(value, { confirm: "auto", ...options });
await animationFrame();
},
/**
* @param {InputValue} value
* @param {FillOptions} [options]
*/
fill: async (value, options) => {
consumeContains();
await focusCurrent();
await fill(value, { confirm: "auto", ...options });
await animationFrame();
},
focus: async () => {
consumeContains();
await focusCurrent();
await animationFrame();
},
hover: async () => {
consumeContains();
await hover(nodePromise);
await animationFrame();
},
/**
* @param {KeyStrokes} keyStrokes
* @param {KeyboardEventInit} [options]
*/
keyDown: async (keyStrokes, options) => {
consumeContains();
await focusCurrent();
await keyDown(keyStrokes, options);
await animationFrame();
},
/**
* @param {KeyStrokes} keyStrokes
* @param {KeyboardEventInit} [options]
*/
keyUp: async (keyStrokes, options) => {
consumeContains();
await focusCurrent();
await keyUp(keyStrokes, options);
await animationFrame();
},
/**
* @param {KeyStrokes} keyStrokes
* @param {KeyboardEventInit} [options]
*/
press: async (keyStrokes, options) => {
consumeContains();
await focusCurrent();
await press(keyStrokes, options);
await animationFrame();
},
/**
* @param {Position} position
*/
scroll: async (position) => {
consumeContains();
// disable "scrollable" check
await scroll(nodePromise, position, { scrollable: false, ...options });
await animationFrame();
},
/**
* @param {InputValue} value
*/
select: async (value) => {
consumeContains();
await select(value, { target: nodePromise });
await animationFrame();
},
/**
* @param {InputValue} value
*/
selectDropdownItem: async (value) => {
consumeContains();
await callClick(click, queryOne(".dropdown-toggle", { root: await nodePromise }));
const item = await waitFor(`.dropdown-item:contains(${value})`);
await callClick(click, item);
await animationFrame();
},
/**
* @param {PointerOptions} [options]
*/
uncheck: async (options) => {
consumeContains();
await uncheck(nodePromise, options);
await animationFrame();
},
};
}
/**
* @param {string} style
*/
export function defineStyle(style) {
const styleEl = document.createElement("style");
styleEl.textContent = style;
document.head.appendChild(styleEl);
after(() => styleEl.remove());
}
/**
* @param {string} value
*/
export async function editAce(value) {
// Ace editor traps focus on "mousedown" events, which are not triggered in
// mobile. To support both environments, a single "mouedown" event is triggered
// in this specific case. This should not be reproduced and is only accepted
// because the tested behaviour comes from a lib on which we have no control.
await manuallyDispatchProgrammaticEvent(queryOne(".ace_editor .ace_content"), "mousedown");
await contains(".ace_editor textarea", { displayed: true, visible: false }).edit(value, {
instantly: true,
});
}

View file

@ -0,0 +1,196 @@
import { after, afterEach, beforeEach, registerDebugInfo } from "@odoo/hoot";
import { startRouter } from "@web/core/browser/router";
import { createDebugContext } from "@web/core/debug/debug_context";
import { translatedTerms, translationLoaded } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { pick } from "@web/core/utils/objects";
import { patch } from "@web/core/utils/patch";
import { makeEnv, startServices } from "@web/env";
import { MockServer, makeMockServer } from "./mock_server/mock_server";
/**
* @typedef {Record<keyof Services, any>} Dependencies
*
* @typedef {import("@web/env").OdooEnv} OdooEnv
*
* @typedef {import("@web/core/registry").Registry} Registry
*
* @typedef {import("services").ServiceFactories} Services
*/
//-----------------------------------------------------------------------------
// Internals
//-----------------------------------------------------------------------------
/**
* TODO: remove when services do not have side effects anymore
* This forsaken block of code ensures that all are properly cleaned up after each
* test because they were populated during the starting process of some services.
*
* @param {Registry} registry
*/
const registerRegistryForCleanup = (registry) => {
const content = Object.entries(registry.content).map(([key, value]) => [key, value.slice()]);
registriesContent.set(registry, content);
for (const subRegistry of Object.values(registry.subRegistries)) {
registerRegistryForCleanup(subRegistry);
}
};
const registriesContent = new WeakMap();
/** @type {OdooEnv | null} */
let currentEnv = null;
// Registers all registries for cleanup in all tests
beforeEach(() => registerRegistryForCleanup(registry));
afterEach(() => restoreRegistry(registry));
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Empties the given registry.
*
* @param {Registry} registry
*/
export function clearRegistry(registry) {
registry.content = {};
registry.elements = null;
registry.entries = null;
}
export function getMockEnv() {
return currentEnv;
}
/**
* @template {keyof Services} T
* @param {T} name
* @returns {Services[T]}
*/
export function getService(name) {
return currentEnv.services[name];
}
/**
* Makes a mock environment along with a mock server
*
* @param {Partial<OdooEnv>} [partialEnv]
* @param {{
* makeNew?: boolean;
* }} [options]
*/
export async function makeMockEnv(partialEnv, options) {
if (currentEnv && !options?.makeNew) {
throw new Error(
`cannot create mock environment: a mock environment has already been declared`
);
}
if (!MockServer.current) {
await makeMockServer();
}
const env = makeEnv();
Object.assign(env, partialEnv, createDebugContext(env)); // This is needed if the views are in debug mode
registerDebugInfo("env", env);
if (!currentEnv) {
currentEnv = env;
startRouter();
after(() => {
currentEnv = null;
// Ideally: should be done in a patch of the localization service, but this
// is less intrusive for now.
if (translatedTerms[translationLoaded]) {
for (const key in translatedTerms) {
delete translatedTerms[key];
}
translatedTerms[translationLoaded] = false;
}
});
}
await startServices(env);
return env;
}
/**
* Makes a mock environment for dialog tests
*
* @param {Partial<OdooEnv>} [partialEnv]
* @returns {Promise<OdooEnv>}
*/
export async function makeDialogMockEnv(partialEnv) {
return makeMockEnv({
...partialEnv,
dialogData: {
close: () => {},
isActive: true,
scrollToOrigin: () => {},
...partialEnv?.dialogData,
},
});
}
/**
* @template {keyof Services} T
* @param {T} name
* @param {Partial<Services[T]> |
* (env: OdooEnv, dependencies: Dependencies) => Services[T]
* } serviceFactory
*/
export function mockService(name, serviceFactory) {
const serviceRegistry = registry.category("services");
const originalService = serviceRegistry.get(name, null);
serviceRegistry.add(
name,
{
...originalService,
start(env, dependencies) {
if (typeof serviceFactory === "function") {
return serviceFactory(env, dependencies);
} else {
const service = originalService.start(env, dependencies);
if (service instanceof Promise) {
service.then((value) => patch(value, serviceFactory));
} else {
patch(service, serviceFactory);
}
return service;
}
},
},
{ force: true }
);
// Patch already initialized service
if (currentEnv?.services?.[name]) {
if (typeof serviceFactory === "function") {
const dependencies = pick(currentEnv.services, ...(originalService.dependencies || []));
currentEnv.services[name] = serviceFactory(currentEnv, dependencies);
} else {
patch(currentEnv.services[name], serviceFactory);
}
}
}
/**
* @param {Registry} registry
*/
export function restoreRegistry(registry) {
if (registriesContent.has(registry)) {
clearRegistry(registry);
registry.content = Object.fromEntries(registriesContent.get(registry));
}
for (const subRegistry of Object.values(registry.subRegistries)) {
restoreRegistry(subRegistry);
}
}

View file

@ -0,0 +1,17 @@
// @odoo-module ignore
// ! WARNING: this module must be loaded after `module_loader` but cannot have dependencies !
(function (odoo) {
"use strict";
if (odoo.define.name.endsWith("(hoot)")) {
return;
}
const name = `${odoo.define.name} (hoot)`;
odoo.define = {
[name](name, dependencies, factory) {
return odoo.loader.define(name, dependencies, factory, !name.endsWith(".hoot"));
},
}[name];
})(globalThis.odoo);

View file

@ -0,0 +1,152 @@
import {
animationFrame,
queryAll,
queryAllAttributes,
queryAllTexts,
queryOne,
} from "@odoo/hoot-dom";
import { contains } from "./dom_test_helpers";
import { buildSelector } from "./view_test_helpers";
import { getDropdownMenu } from "./component_test_helpers";
/**
* @param {number} [columnIndex=0]
*/
export function clickKanbanLoadMore(columnIndex = 0) {
return contains(".o_kanban_load_more button", { root: getKanbanColumn(columnIndex) }).click();
}
/**
* @param {SelectorOptions} [options]
*/
export async function clickKanbanRecord(options) {
await contains(buildSelector(`.o_kanban_record`, options)).click();
}
export async function createKanbanRecord() {
await contains(".o_control_panel_main_buttons button.o-kanban-button-new").click();
return animationFrame(); // the kanban quick create is rendered in a second animation frame
}
export function discardKanbanRecord() {
return contains(".o_kanban_quick_create .o_kanban_cancel").click();
}
/**
* @param {string} value
*/
export function editKanbanColumnName(value) {
return contains(".o_column_quick_create input").edit(value);
}
export function editKanbanRecord() {
return contains(".o_kanban_quick_create .o_kanban_edit").click();
}
/**
* @param {string} fieldName
* @param {string} value
*/
export function editKanbanRecordQuickCreateInput(fieldName, value) {
return contains(`.o_kanban_quick_create .o_field_widget[name=${fieldName}] input`).edit(value, {
confirm: "tab",
});
}
/**
* @param {number} [columnIndex=0]
* @param {boolean} [ignoreFolded=false]
*/
export function getKanbanColumn(columnIndex = 0, ignoreFolded = false) {
let selector = ".o_kanban_group";
if (ignoreFolded) {
selector += ":not(.o_column_folded)";
}
return queryAll(selector).at(columnIndex);
}
/**
* @param {number} [columnIndex=0]
* @param {boolean} [ignoreFolded=false]
*/
export function getKanbanColumnDropdownMenu(columnIndex = 0, ignoreFolded = false) {
const column = getKanbanColumn(columnIndex, ignoreFolded);
return getDropdownMenu(column);
}
/**
* @param {number} [columnIndex]
*/
export function getKanbanColumnTooltips(columnIndex) {
queryAllAttributes;
const root = columnIndex >= 0 && getKanbanColumn(columnIndex);
return queryAllAttributes(".o_column_progress .progress-bar", "data-tooltip", { root });
}
export function getKanbanCounters() {
return queryAllTexts(".o_animated_number");
}
/**
* @param {number} [columnIndex=0]
*/
export function getKanbanProgressBars(columnIndex = 0) {
const column = getKanbanColumn(columnIndex);
return queryAll(".o_column_progress .progress-bar", { root: column });
}
/**
* @param {SelectorOptions} options
*/
export function getKanbanRecord(options) {
return queryOne(buildSelector(`.o_kanban_record`, options));
}
/**
* @param {number} [columnIndex]
*/
export function getKanbanRecordTexts(columnIndex) {
const root = columnIndex >= 0 && getKanbanColumn(columnIndex);
return queryAllTexts(".o_kanban_record:not(.o_kanban_ghost)", { root });
}
export function quickCreateKanbanColumn() {
return contains(".o_column_quick_create > .o_quick_create_folded").click();
}
/**
* @param {number} [columnIndex=0]
*/
export async function quickCreateKanbanRecord(columnIndex = 0) {
await contains(".o_kanban_quick_add", { root: getKanbanColumn(columnIndex) }).click();
return animationFrame(); // the kanban quick create is rendered in a second animation frame
}
/**
* @param {number} [columnIndex=0]
*/
export async function toggleKanbanColumnActions(columnIndex = 0) {
const column = getKanbanColumn(columnIndex);
await contains(".o_kanban_config .dropdown-toggle", { root: column, visible: false }).click();
return (buttonText) => {
const menu = getDropdownMenu(column);
return contains(`.dropdown-item:contains(/\\b${buttonText}\\b/i)`, { root: menu }).click();
};
}
/**
* @param {number} [recordIndex=0]
*/
export function toggleKanbanRecordDropdown(recordIndex = 0) {
return contains(`.o_kanban_record:eq(${recordIndex}) .o_dropdown_kanban .dropdown-toggle`, {
visible: false,
}).click();
}
export function validateKanbanColumn() {
return contains(".o_column_quick_create .o_kanban_add").click();
}
export function validateKanbanRecord() {
return contains(".o_kanban_quick_create .o_kanban_add").click();
}

View file

@ -0,0 +1,56 @@
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
import { mockLocation } from "@odoo/hoot-mock";
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* List of properties that should not be mocked on the browser object.
*
* This is because they are already handled by HOOT and tampering with them could
* lead to unexpected behavior.
*/
const READONLY_PROPERTIES = [
"cancelAnimationFrame",
"clearInterval",
"clearTimeout",
"requestAnimationFrame",
"setInterval",
"setTimeout",
];
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Browser module needs to be mocked to patch the `location` global object since
* it can't be directly mocked on the window object.
*
* @param {string} name
* @param {OdooModuleFactory} factory
*/
export function mockBrowserFactory(name, { fn }) {
return (...args) => {
const browserModule = fn(...args);
const properties = {
location: {
get: () => mockLocation,
set: (value) => (mockLocation.href = value),
},
};
for (const property of READONLY_PROPERTIES) {
const originalValue = browserModule.browser[property];
properties[property] = {
configurable: false,
get: () => originalValue,
};
}
Object.defineProperties(browserModule.browser, properties);
return browserModule;
};
}

View file

@ -0,0 +1,33 @@
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
import { onServerStateChange } from "./mock_server_state.hoot";
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {import("./mock_server_state.hoot").ServerState} serverState
*/
const makeCurrencies = ({ currencies }) =>
Object.fromEntries(
currencies.map((currency) => [currency.id, { digits: [69, 2], ...currency }])
);
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {string} name
* @param {OdooModuleFactory} factory
*/
export function mockCurrencyFactory(name, { fn }) {
return (requireModule, ...args) => {
const currencyModule = fn(requireModule, ...args);
onServerStateChange(currencyModule.currencies, makeCurrencies);
return currencyModule;
};
}

View file

@ -0,0 +1,224 @@
import { MockServerError } from "./mock_server_utils";
/**
* @typedef {import("fields").INumerical["aggregator"]} Aggregator
* @typedef {import("fields").FieldDefinition} FieldDefinition
* @typedef {import("fields").FieldDefinitionsByType} FieldDefinitionsByType
* @typedef {import("fields").FieldType} FieldType
* @typedef {import("./mock_model").ModelRecord} ModelRecord
*
* @typedef {{
* compute?: (() => void) | string;
* default?: RecordFieldValue | ((record: ModelRecord) => RecordFieldValue);
* onChange?: (record: ModelRecord) => void;
* }} MockFieldProperties
*
* @typedef {number | string | boolean | number[]} RecordFieldValue
*/
/**
* @param {string} name
*/
function camelToPascal(name) {
return (
name[0].toUpperCase() + name.slice(1).replace(R_CAMEL_CASE, (_, char) => char.toUpperCase())
);
}
/**
* This function spawns a 2-level process to create field definitions: it's a function
* returning a function returning a field descriptor.
*
* - this function ("generator") is called at the end of this file with pre-defined
* parameters to configure the "constructor" functions, i.e. the ones that will
* be called in the tests model definitions;
*
* - those "constructor" functions will then be called in model definitions and will
* return the actual field descriptors.
*
* @template {FieldType} T
* @template [R=never]
* @param {T} type
* @param {{
* aggregator?: Aggregator;
* requiredKeys?: R[];
* }} params
*/
function makeFieldGenerator(type, { aggregator, requiredKeys = [] } = {}) {
const constructorFnName = camelToPascal(type);
const defaultDef = { ...DEFAULT_FIELD_PROPERTIES };
if (aggregator) {
defaultDef.aggregator = aggregator;
}
if (type !== "generic") {
defaultDef.type = type;
}
// 2nd level: returns the "constructor" function
return {
/**
* @param {Partial<FieldDefinitionsByType[T] & MockFieldProperties>} [properties]
*/
[constructorFnName](properties) {
// Creates a pre-version of the field definition
const field = {
...defaultDef,
...properties,
[S_FIELD]: true,
};
for (const key of requiredKeys) {
if (!(key in field)) {
throw new MockServerError(
`missing key "${key}" in ${type || "generic"} field definition`
);
}
}
// Fill default values in definition based on given properties
if (isComputed(field)) {
// By default: computed fields are readonly and not stored
field.readonly = properties.readonly ?? true;
field.store = properties.store ?? false;
}
return field;
},
}[constructorFnName];
}
const R_CAMEL_CASE = /_([a-z])/g;
const R_ENDS_WITH_ID = /_id(s)?$/i;
const R_LOWER_FOLLOWED_BY_UPPER = /([a-z])([A-Z])/g;
const R_SPACE_OR_UNDERSCORE = /[\s_]+/g;
/**
* @param {FieldDefinition & MockFieldProperties} field
*/
export function isComputed(field) {
return globalThis.Boolean(field.compute || field.related);
}
/**
* @param {unknown} value
*/
export function getFieldDisplayName(value) {
const str = String(value)
.replace(R_ENDS_WITH_ID, "$1")
.replace(R_LOWER_FOLLOWED_BY_UPPER, (_, a, b) => `${a} ${b.toLowerCase()}`)
.replace(R_SPACE_OR_UNDERSCORE, " ")
.trim();
return str[0].toUpperCase() + str.slice(1);
}
// Default field values
export const DEFAULT_MONEY_FIELD_VALUES = {
monetary: () => 0,
};
export const DEFAULT_RELATIONAL_FIELD_VALUES = {
many2many: () => [],
many2one: () => false,
many2one_reference: () => false,
one2many: () => [],
};
export const DEFAULT_SELECTION_FIELD_VALUES = {
reference: () => false,
selection: () => false,
};
export const DEFAULT_STANDARD_FIELD_VALUES = {
binary: () => false,
boolean: () => false,
char: () => false,
date: () => false,
datetime: () => false,
float: () => 0,
html: () => false,
number: () => 0,
image: () => false,
integer: () => 0,
json: () => false,
properties: () => false,
properties_definition: () => false,
text: () => false,
};
export const DEFAULT_FIELD_VALUES = {
...DEFAULT_MONEY_FIELD_VALUES,
...DEFAULT_RELATIONAL_FIELD_VALUES,
...DEFAULT_SELECTION_FIELD_VALUES,
...DEFAULT_STANDARD_FIELD_VALUES,
};
export const DEFAULT_FIELD_PROPERTIES = {
readonly: false,
required: false,
searchable: true,
sortable: true,
store: true,
groupable: true,
};
export const S_FIELD = Symbol("field");
export const S_SERVER_FIELD = Symbol("field");
export const Binary = makeFieldGenerator("binary");
export const Boolean = makeFieldGenerator("boolean");
export const Char = makeFieldGenerator("char");
export const Date = makeFieldGenerator("date");
export const Datetime = makeFieldGenerator("datetime");
export const Float = makeFieldGenerator("float", {
aggregator: "sum",
});
export const Generic = makeFieldGenerator("generic");
export const Html = makeFieldGenerator("html");
export const Image = makeFieldGenerator("image");
export const Integer = makeFieldGenerator("integer", {
aggregator: "sum",
});
export const Json = makeFieldGenerator("json");
export const Many2many = makeFieldGenerator("many2many", {
requiredKeys: ["relation"],
});
export const Many2one = makeFieldGenerator("many2one", {
requiredKeys: ["relation"],
});
export const Many2oneReference = makeFieldGenerator("many2one_reference", {
requiredKeys: ["model_field", "relation"],
});
export const Monetary = makeFieldGenerator("monetary", {
aggregator: "sum",
requiredKeys: ["currency_field"],
});
export const One2many = makeFieldGenerator("one2many", {
requiredKeys: ["relation"],
});
export const Properties = makeFieldGenerator("properties", {
requiredKeys: ["definition_record", "definition_record_field"],
});
export const PropertiesDefinition = makeFieldGenerator("properties_definition");
export const Reference = makeFieldGenerator("reference", {
requiredKeys: ["selection"],
});
export const Selection = makeFieldGenerator("selection", {
requiredKeys: ["selection"],
});
export const Text = makeFieldGenerator("text");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
declare module "mock_models" {
import { webModels } from "@web/../tests/web_test_helpers";
export interface IrModelFields extends webModels.IrModelFields {}
export interface ResGroups extends webModels.ResGroups {}
export interface Models {
"ir.model.fields": IrModelFields;
"res.groups": ResGroups;
}
}

View file

@ -0,0 +1,5 @@
import { ServerModel } from "../mock_model";
export class IrAttachment extends ServerModel {
_name = "ir.attachment";
}

View file

@ -0,0 +1,13 @@
import { ServerModel } from "../mock_model";
export class IrModel extends ServerModel {
_name = "ir.model";
_records = [
{
id: 1,
model: "res.partner",
name: "Partner",
},
];
}

View file

@ -0,0 +1,9 @@
import { Model } from "../mock_model";
export class IrModelAccess extends Model {
_name = "ir.model.access";
has_access() {
return true;
}
}

View file

@ -0,0 +1,5 @@
import { ServerModel } from "../mock_model";
export class IrModelFields extends ServerModel {
_name = "ir.model.fields";
}

View file

@ -0,0 +1,9 @@
import { Model } from "../mock_model";
export class IrRule extends Model {
_name = "ir.rule";
has_access() {
return true;
}
}

View file

@ -0,0 +1,9 @@
import { ServerModel } from "../mock_model";
export class IrUiView extends ServerModel {
_name = "ir.ui.view";
has_access() {
return true;
}
}

View file

@ -0,0 +1,17 @@
import { serverState } from "../../mock_server_state.hoot";
import * as fields from "../mock_fields";
import { ServerModel } from "../mock_model";
export class ResCompany extends ServerModel {
_name = "res.company";
description = fields.Text();
is_company = fields.Boolean({ default: false });
_records = serverState.companies.map((company) => ({
id: company.id,
active: true,
name: company.name,
partner_id: company.id,
}));
}

View file

@ -0,0 +1,5 @@
import { ServerModel } from "../mock_model";
export class ResCountry extends ServerModel {
_name = "res.country";
}

View file

@ -0,0 +1,16 @@
import { serverState } from "../../mock_server_state.hoot";
import { ServerModel } from "../mock_model";
export class ResCurrency extends ServerModel {
_name = "res.currency";
_records = Object.entries(serverState.currencies).map(
([id, { digits, name, position, symbol }]) => ({
id: Number(id) + 1,
decimal_places: digits?.at(-1) ?? 2,
name,
position,
symbol,
})
);
}

View file

@ -0,0 +1,13 @@
import { serverState } from "../../mock_server_state.hoot";
import { ServerModel } from "../mock_model";
export class ResGroups extends ServerModel {
_name = "res.groups";
_records = [
{
id: serverState.groupId,
name: "Internal User",
},
];
}

View file

@ -0,0 +1,31 @@
import { serverState } from "../../mock_server_state.hoot";
import { ServerModel } from "../mock_model";
export class ResPartner extends ServerModel {
_name = "res.partner";
_records = [
...serverState.companies.map((company) => ({
id: company.id,
active: true,
name: company.name,
})),
{
id: serverState.partnerId,
active: true,
name: serverState.partnerName,
},
{
id: serverState.publicPartnerId,
active: true,
is_public: true,
name: serverState.publicPartnerName,
},
{
id: serverState.odoobotId,
active: false,
im_status: "bot",
name: "OdooBot",
},
];
}

View file

@ -0,0 +1,46 @@
import { serverState } from "../../mock_server_state.hoot";
import { ServerModel } from "../mock_model";
export class ResUsers extends ServerModel {
_name = "res.users";
_records = [
{
id: serverState.userId,
active: true,
company_id: serverState.companies[0]?.id,
company_ids: serverState.companies.map((company) => company.id),
login: "admin",
partner_id: serverState.partnerId,
password: "admin",
},
{
id: serverState.publicUserId,
active: false,
login: "public",
partner_id: serverState.publicPartnerId,
password: "public",
},
];
has_group() {
return true;
}
_is_public(id) {
return id === serverState.publicUserId;
}
/**
* @override
* @type {ServerModel["create"]}
*/
create() {
const userId = super.create(...arguments);
const [user] = this.env["res.users"].browse(userId);
if (user && !user.partner_id) {
this.env["res.users"].write(userId, { partner_id: this.env["res.partner"].create({}) });
}
return userId;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,126 @@
import { makeErrorFromResponse } from "@web/core/network/rpc";
/**
* @template T
* @typedef {import("./mock_server").KwArgs<T>} KwArgs
*/
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* This is a flag on keyword arguments, so that they can easily be distinguished
* from args in ORM methods. They can then be easily retrieved with {@link getKwArgs}.
*/
const KWARGS_SYMBOL = Symbol("is_kwargs");
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Flags keyword arguments, so that they can easily be distinguished from regular
* arguments in ORM methods.
*
* They can then be easily retrieved with {@link getKwArgs}.
*
* @template T
* @param {T} kwargs
* @returns {T}
*/
export function makeKwArgs(kwargs) {
kwargs[KWARGS_SYMBOL] = true;
return kwargs;
}
/**
* Retrieves keyword arguments flagged by {@link makeKwArgs} from an arguments list.
*
* @template {string} T
* @param {Iterable<any>} allArgs arguments of method
* @param {...T} argNames ordered names of positional arguments
* @returns {KwArgs<Record<T, any>>} kwargs normalized params
*/
export function getKwArgs(allArgs, ...argNames) {
const args = [...allArgs];
const kwargs = args.at(-1)?.[KWARGS_SYMBOL] ? args.pop() : makeKwArgs({});
if (args.length > argNames.length) {
throw new MockServerError("more positional arguments than there are given argument names");
}
for (let i = 0; i < args.length; i++) {
if (args[i] !== null && args[i] !== undefined) {
kwargs[argNames[i]] = args[i];
}
}
return kwargs;
}
/**
* @param {import("./mock_model").ModelRecord} record
*/
export function getRecordQualifier(record) {
if (record.id) {
return `record #${record.id}`;
}
const name = record.display_name || record.name;
if (name) {
return `record named "${name}"`;
}
return "anonymous record";
}
/**
* @param {Record<string, string | any>} params
*/
export function makeServerError({
code,
context,
description,
message,
subType,
errorName,
type,
args,
} = {}) {
return makeErrorFromResponse({
code: code || 200,
message: message || "Odoo Server Error",
data: {
name: errorName || `odoo.exceptions.${type || "UserError"}`,
debug: "traceback",
arguments: args || [],
context: context || {},
subType,
message: description,
},
});
}
/**
* @param {unknown} value
* @param {string} [separator=","]
*/
export function safeSplit(value, separator) {
return value
? String(value)
.trim()
.split(separator || ",")
: [];
}
/**
* Removes the flag for keyword arguments.
*
* @template T
* @param {T} kwargs
* @returns {T}
*/
export function unmakeKwArgs(kwargs) {
delete kwargs[KWARGS_SYMBOL];
return kwargs;
}
export class MockServerError extends Error {
name = "MockServerError";
}

View file

@ -0,0 +1,161 @@
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
import { after, before, beforeEach, createJobScopedGetter } from "@odoo/hoot";
import { validateType } from "@odoo/owl";
const { view_info } = odoo.__session_info__ || {};
delete odoo.__session_info__;
const { Settings } = luxon;
/**
* @typedef {typeof SERVER_STATE_VALUES} ServerState
*/
const applyDefaults = () => {
Object.assign(Settings, DEFAULT_LUXON_SETTINGS);
notifySubscribers();
};
const notifySubscribers = () => {
// Apply new state to all subscribers
for (const [target, callback] of subscriptions) {
const descriptors = Object.getOwnPropertyDescriptors(callback(serverState));
Object.defineProperties(target, descriptors);
}
};
const DEFAULT_LUXON_SETTINGS = {
defaultLocale: Settings.defaultLocale,
defaultNumberingSystem: Settings.defaultNumberingSystem,
defaultOutputCalendar: Settings.defaultOutputCalendar,
defaultZone: Settings.defaultZone,
defaultWeekSettings: Settings.defaultWeekSettings,
};
const SERVER_STATE_VALUES = {
companies: [
{
id: 1,
name: "Hermit",
},
],
currencies: [
{
id: 1,
name: "USD",
position: "before",
symbol: "$",
},
{
id: 2,
name: "EUR",
position: "after",
symbol: "€",
},
],
db: "test",
debug: "",
groupId: 11,
lang: "en",
multiLang: false,
odoobotId: 418,
partnerId: 17,
partnerName: "Mitchell Admin",
publicPartnerId: 18,
publicPartnerName: "Public user",
publicUserId: 8,
serverVersion: [1, 0, 0, "final", 0, ""],
timezone: "taht",
userContext: {},
userId: 7,
view_info,
};
const SERVER_STATE_VALUES_SCHEMA = {
companies: { type: Array, element: Object },
currencies: { type: Array, element: Object },
db: String,
debug: String,
groupId: [Number, { value: false }],
lang: String,
multiLang: Boolean,
odoobotId: [Number, { value: false }],
partnerId: [Number, { value: false }],
partnerName: String,
publicPartnerId: [Number, { value: false }],
publicPartnerName: String,
publicUserId: Number,
serverVersion: { type: Array, element: [String, Number] },
timezone: String,
userContext: Object,
userId: [Number, { value: false }],
view_info: Object,
};
const getServerStateValues = createJobScopedGetter(
(previousValues) => ({
...JSON.parse(JSON.stringify(SERVER_STATE_VALUES)),
...previousValues,
}),
applyDefaults
);
/** @type {Map<any, (state: ServerState) => any>} */
const subscriptions = new Map([
[
odoo,
({ db, debug, serverVersion }) => ({
...odoo,
debug,
info: {
db,
server_version: serverVersion.slice(0, 2).join("."),
server_version_info: serverVersion,
isEnterprise: serverVersion.slice(-1)[0] === "e",
},
isReady: true,
}),
],
]);
/**
* @template T
* @param {T} target
* @param {(state: ServerState) => T} callback
*/
export function onServerStateChange(target, callback) {
before(() => {
subscriptions.set(target, callback);
});
after(() => {
subscriptions.delete(target);
});
}
export const serverState = new Proxy(SERVER_STATE_VALUES, {
deleteProperty(_target, p) {
return Reflect.deleteProperty(getServerStateValues(), p);
},
get(_target, p) {
return Reflect.get(getServerStateValues(), p);
},
has(_target, p) {
return Reflect.has(getServerStateValues(), p);
},
set(_target, p, newValue) {
if (p in SERVER_STATE_VALUES_SCHEMA && newValue !== null && newValue !== undefined) {
const errorMessage = validateType(p, newValue, SERVER_STATE_VALUES_SCHEMA[p]);
if (errorMessage) {
throw new TypeError(errorMessage);
}
}
const result = Reflect.set(getServerStateValues(), p, newValue);
if (result) {
notifySubscribers();
}
return result;
},
});
beforeEach(applyDefaults, { global: true });

View file

@ -0,0 +1,82 @@
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
import { onServerStateChange, serverState } from "./mock_server_state.hoot";
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {typeof serverState} serverState
*/
const makeSession = ({
companies,
db,
lang,
partnerId,
partnerName,
serverVersion,
timezone,
userContext,
userId,
view_info,
}) => ({
active_ids_limit: 20000,
bundle_params: {
debug: new URLSearchParams(location.search).get("debug"),
lang,
},
cache_hashes: {
load_menus: "164b675eb9bf49f8bca52e350cd81482a8cf0d0c1c8a47d99bd063c0a0bf4f0d",
translations: "f17c8e4bb0fd4d5db2615d28713486df97853a8f",
},
can_insert_in_spreadsheet: true,
db,
display_switch_company_menu: false,
home_action_id: false,
is_admin: true,
is_internal_user: true,
is_system: true,
max_file_upload_size: 134217728,
name: partnerName,
partner_display_name: partnerName,
partner_id: partnerId,
profile_collectors: null,
profile_params: null,
profile_session: null,
server_version: serverVersion.slice(0, 2).join("."),
server_version_info: serverVersion,
show_effect: true,
uid: userId,
// Commit: 3e847fc8f499c96b8f2d072ab19f35e105fd7749
// to see what user_companies is
user_companies: {
allowed_companies: Object.fromEntries(companies.map((company) => [company.id, company])),
current_company: companies[0]?.id,
disallowed_ancestor_companies: {},
},
user_context: {
...userContext,
lang,
tz: timezone,
uid: userId,
},
user_id: [userId],
username: "admin",
["web.base.url"]: "http://localhost:8069",
view_info,
});
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export function mockSessionFactory() {
return () => {
const session = makeSession(serverState);
onServerStateChange(session, makeSession);
return { session };
};
}

View file

@ -0,0 +1,94 @@
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* We remove all the `src` attributes (and derived forms) from the template and
* replace them by data attributes (e.g. `src` to `data-src`, `t-att-src` to
* `t-att-data-src`). This is done to ensure images will not trigger an actual request
* on the server.
*
* @param {Element} template
*/
const replaceAttributes = (template) => {
for (const [tagName, value] of SRC_REPLACERS) {
for (const prefix of ATTRIBUTE_PREFIXES) {
const targetAttribute = `${prefix}src`;
const dataAttribute = `${prefix}data-src`;
for (const element of template.querySelectorAll(`${tagName}[${targetAttribute}]`)) {
element.setAttribute(dataAttribute, element.getAttribute(targetAttribute));
if (prefix) {
element.removeAttribute(targetAttribute);
}
element.setAttribute("src", value);
}
}
}
};
const ONE_FUSCHIA_PIXEL_IMG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z9DwHwAGBQKA3H7sNwAAAABJRU5ErkJggg==";
const SRC_REPLACERS = [
["iframe", ""],
["img", ONE_FUSCHIA_PIXEL_IMG],
];
const ATTRIBUTE_PREFIXES = ["", "t-att-", "t-attf-"];
const { loader } = odoo;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {string} name
* @param {OdooModuleFactory} factory
*/
export function makeTemplateFactory(name, factory) {
return () => {
if (loader.modules.has(name)) {
return loader.modules.get(name);
}
/** @type {Map<string, function>} */
const compiledTemplates = new Map();
const factoryFn = factory.fn;
factory.fn = (...args) => {
const exports = factoryFn(...args);
const { clearProcessedTemplates, getTemplate } = exports;
// Patch "getTemplates" to access local cache
exports.getTemplate = function mockedGetTemplate(name) {
if (!this) {
// Used outside of Owl.
return getTemplate(name);
}
const rawTemplate = getTemplate(name) || this.rawTemplates[name];
if (typeof rawTemplate === "function" && !(rawTemplate instanceof Element)) {
return rawTemplate;
}
if (!compiledTemplates.has(rawTemplate)) {
compiledTemplates.set(rawTemplate, this._compileTemplate(name, rawTemplate));
}
return compiledTemplates.get(rawTemplate);
};
// Patch "clearProcessedTemplates" to clear local template cache
exports.clearProcessedTemplates = function mockedClearProcessedTemplates() {
compiledTemplates.clear();
return clearProcessedTemplates(...arguments);
};
// Replace alt & src attributes by default on all templates
exports.registerTemplateProcessor(replaceAttributes);
return exports;
};
return loader.startModule(name);
};
}

View file

@ -0,0 +1,22 @@
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
import { onServerStateChange } from "./mock_server_state.hoot";
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {string} name
* @param {OdooModuleFactory} factory
*/
export function mockUserFactory(name, { fn }) {
return (requireModule, ...args) => {
const { session } = requireModule("@web/session");
const userModule = fn(requireModule, ...args);
onServerStateChange(userModule.user, () => userModule._makeUser(session));
return userModule;
};
}

View file

@ -0,0 +1,731 @@
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
import { describe, dryRun, globals, start, stop } from "@odoo/hoot";
import { Deferred, delay } from "@odoo/hoot-dom";
import { watchAddedNodes, watchKeys, watchListeners } from "@odoo/hoot-mock";
import { mockBrowserFactory } from "./mock_browser.hoot";
import { mockCurrencyFactory } from "./mock_currency.hoot";
import { mockSessionFactory } from "./mock_session.hoot";
import { makeTemplateFactory } from "./mock_templates.hoot";
import { mockUserFactory } from "./mock_user.hoot";
/**
* @typedef {{
* addonsKey: string;
* filter?: (path: string) => boolean;
* moduleNames: string[];
* }} ModuleSet
*
* @typedef {{
* addons?: Iterable<string>;
* }} ModuleSetParams
*/
const { fetch: realFetch } = globals;
const { define, loader } = odoo;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {Record<any, any>} object
*/
function clearObject(object) {
for (const key in object) {
delete object[key];
}
}
/**
* @param {string} fileSuffix
* @param {string[]} entryPoints
* @param {Set<string>} additionalAddons
*/
async function defineModuleSet(fileSuffix, entryPoints, additionalAddons) {
/** @type {ModuleSet} */
const moduleSet = {};
if (additionalAddons.has("*")) {
// Use all addons
moduleSet.addonsKey = "*";
moduleSet.moduleNames = sortedModuleNames.filter((name) => !name.endsWith(fileSuffix));
} else {
// Use subset of addons
for (const entryPoint of entryPoints) {
additionalAddons.add(getAddonName(entryPoint));
}
const addons = await fetchDependencies(additionalAddons);
for (const addon in AUTO_INCLUDED_ADDONS) {
if (addons.has(addon)) {
for (const toInclude of AUTO_INCLUDED_ADDONS[addon]) {
addons.add(toInclude);
}
}
}
const filter = (path) => addons.has(getAddonName(path));
// Module names are cached for each configuration of addons
const joinedAddons = [...addons].sort().join(",");
if (!moduleNamesCache.has(joinedAddons)) {
moduleNamesCache.set(
joinedAddons,
sortedModuleNames.filter((name) => !name.endsWith(fileSuffix) && filter(name))
);
}
moduleSet.addonsKey = joinedAddons;
moduleSet.filter = filter;
moduleSet.moduleNames = moduleNamesCache.get(joinedAddons);
}
return moduleSet;
}
/**
* @param {string} fileSuffix
* @param {string[]} entryPoints
*/
async function describeDrySuite(fileSuffix, entryPoints) {
const moduleSet = await defineModuleSet(fileSuffix, entryPoints, new Set(["*"]));
const moduleSetLoader = new ModuleSetLoader(moduleSet);
moduleSetLoader.setup();
for (const entryPoint of entryPoints) {
// Run test factory
describe(getSuitePath(entryPoint), () => {
// Load entry point module
const fullModuleName = entryPoint + fileSuffix;
const module = moduleSetLoader.startModule(fullModuleName);
// Check exports (shouldn't have any)
const exports = Object.keys(module || {});
if (exports.length) {
throw new Error(
`Test files cannot have exports, found the following exported member(s) in module ${fullModuleName}:${exports
.map((name) => `\n - ${name}`)
.join("")}`
);
}
});
}
await moduleSetLoader.cleanup();
}
/**
* @param {Set<string>} addons
*/
async function fetchDependencies(addons) {
// Fetch missing dependencies
const addonsToFetch = [];
for (const addon of addons) {
if (!dependencyCache[addon] && !DEFAULT_ADDONS.includes(addon)) {
addonsToFetch.push(addon);
dependencyCache[addon] = new Deferred();
}
}
if (addonsToFetch.length) {
if (!dependencyBatch.length) {
dependencyBatchPromise = Deferred.resolve().then(() => {
const module_names = [...new Set(dependencyBatch)];
dependencyBatch = [];
return orm("ir.module.module.dependency", "all_dependencies", [], { module_names });
});
}
dependencyBatch.push(...addonsToFetch);
dependencyBatchPromise.then((allDependencies) => {
for (const [moduleName, dependencyNames] of Object.entries(allDependencies)) {
dependencyCache[moduleName] ||= new Deferred();
dependencyCache[moduleName].resolve();
dependencies[moduleName] = dependencyNames.filter(
(dep) => !DEFAULT_ADDONS.includes(dep)
);
}
resolveAddonDependencies(dependencies);
});
}
await Promise.all([...addons].map((addon) => dependencyCache[addon]));
return getDependencies(addons);
}
/**
* @param {string} name
*/
function findMockFactory(name) {
if (MODULE_MOCKS_BY_NAME.has(name)) {
return MODULE_MOCKS_BY_NAME.get(name);
}
for (const [key, factory] of MODULE_MOCKS_BY_REGEX) {
if (key instanceof RegExp && key.test(name)) {
return factory;
}
}
return null;
}
/**
* Reduce the size of the given field and freeze it.
*
* @param {Record<string, unknown>>} field
*/
function freezeField(field) {
delete field.name;
if (field.groupable) {
delete field.groupable;
}
if (!field.readonly && !field.related) {
delete field.readonly;
}
if (!field.required) {
delete field.required;
}
if (field.searchable) {
delete field.searchable;
}
if (field.sortable) {
delete field.sortable;
}
if (field.store && !field.related) {
delete field.store;
}
return Object.freeze(field);
}
/**
* Reduce the size of the given model and freeze it.
*
* @param {Record<string, unknown>>} model
*/
function freezeModel(model) {
if (model.fields) {
for (const [fieldName, field] of Object.entries(model.fields)) {
model.fields[fieldName] = freezeField(field);
}
Object.freeze(model.fields);
}
if (model.inherit) {
if (model.inherit.length) {
model.inherit = model.inherit.filter((m) => m !== "base");
}
if (!model.inherit.length) {
delete model.inherit;
}
}
if (model.order === "id") {
delete model.order;
}
if (model.parent_name === "parent_id") {
delete model.parent_name;
}
if (model.rec_name === "name") {
delete model.rec_name;
}
return Object.freeze(model);
}
/**
* @param {string} name
*/
function getAddonName(name) {
return name.match(R_PATH_ADDON)?.[1];
}
/**
* @param {Iterable<string>} addons
*/
function getDependencies(addons) {
const result = new Set(DEFAULT_ADDONS);
for (const addon of addons) {
if (DEFAULT_ADDONS.includes(addon)) {
continue;
}
result.add(addon);
for (const dep of dependencies[addon]) {
result.add(dep);
}
}
return result;
}
/**
* @param {string} name
*/
function getSuitePath(name) {
return name.replace("../tests/", "");
}
/**
* Keeps the original definition of a factory.
*
* @param {string} name
*/
function makeFixedFactory(name) {
return () => {
if (!loader.modules.has(name)) {
loader.startModule(name);
}
return loader.modules.get(name);
};
}
/**
* Toned-down version of the RPC + ORM features since this file cannot depend on
* them.
*
* @param {string} model
* @param {string} method
* @param {any[]} args
* @param {Record<string, any>} kwargs
*/
async function orm(model, method, args, kwargs) {
const response = await realFetch(`/web/dataset/call_kw/${model}/${method}`, {
body: JSON.stringify({
id: nextRpcId++,
jsonrpc: "2.0",
method: "call",
params: { args, kwargs, method, model },
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const { error, result } = await response.json();
if (error) {
throw error;
}
return result;
}
/**
* @template {Record<string, string[]>} T
* @param {T} dependencies
*/
function resolveAddonDependencies(dependencies) {
const findJob = () =>
Object.entries(remaining).find(([, deps]) => deps.every((dep) => dep in solved));
const remaining = { ...dependencies };
/** @type {T} */
const solved = {};
let entry;
while ((entry = findJob())) {
const [name, deps] = entry;
solved[name] = [...new Set(deps.flatMap((dep) => [dep, ...solved[dep]]))];
delete remaining[name];
}
const remainingKeys = Object.keys(remaining);
if (remainingKeys.length) {
throw new Error(`Unresolved dependencies: ${remainingKeys.join(", ")}`);
}
Object.assign(dependencies, solved);
}
/**
* @param {Record<string, unknown>>} model
*/
function unfreezeModel(model) {
const fields = Object.create(null);
if (model.fields) {
for (const [fieldName, field] of Object.entries(model.fields)) {
fields[fieldName] = { ...field };
}
}
return { ...model, fields };
}
/**
* This method tries to manually run the garbage collector (if exposed) and logs
* the current heap size (if available). It is meant to be called right after a
* suite's module set has been fully executed.
*
* This is used for debugging memory leaks, or if the containing process running
* unit tests doesn't know how much available memory it actually has.
*
* To enable this feature, the containing process must simply use the `--expose-gc`
* flag.
*
* @param {string} label
* @param {number} [testCount]
*/
async function __gcAndLogMemory(label, testCount) {
if (typeof window.gc !== "function") {
return;
}
// Cleanup last retained textarea
const textarea = document.createElement("textarea");
document.body.appendChild(textarea);
textarea.value = "aaa";
textarea.focus();
textarea.remove();
// Run garbage collection
await window.gc({ type: "major", execution: "async" });
// Log memory usage
const logs = [
`[MEMINFO] ${label} (after GC)`,
"- used:",
window.performance.memory.usedJSHeapSize,
"- total:",
window.performance.memory.totalJSHeapSize,
"- limit:",
window.performance.memory.jsHeapSizeLimit,
];
if (Number.isInteger(testCount)) {
logs.push("- tests:", testCount);
}
console.log(...logs);
}
/** @extends {OdooModuleLoader} */
class ModuleSetLoader extends loader.constructor {
cleanups = [];
preventGlobalDefine = false;
/**
* @param {ModuleSet} moduleSet
*/
constructor(moduleSet) {
super();
this.factories = new Map(loader.factories);
this.modules = new Map(loader.modules);
this.moduleSet = moduleSet;
odoo.define = this.define.bind(this);
odoo.loader = this;
}
/**
* @override
* @type {typeof loader["addJob"]}
*/
addJob(name) {
if (this.canAddModule(name)) {
super.addJob(...arguments);
}
}
/**
* @param {string} name
*/
canAddModule(name) {
const { filter } = this.moduleSet;
return !filter || filter(name) || R_DEFAULT_MODULE.test(name);
}
async cleanup() {
// Wait for side-effects to be properly caught up by the different "watchers"
// (like mutation records).
await delay();
odoo.define = define;
odoo.loader = loader;
while (this.cleanups.length) {
this.cleanups.pop()();
}
}
/**
* @override
* @type {typeof loader["define"]}
*/
define(name, deps, factory) {
if (!this.preventGlobalDefine && !loader.factories.has(name)) {
// Lazy-loaded modules are added to the main loader for next ModuleSetLoader
// instances.
loader.define(name, deps, factory, true);
// We assume that lazy-loaded modules are not required by any other
// module.
sortedModuleNames.push(name);
moduleNamesCache.clear();
}
return super.define(...arguments);
}
setup() {
this.cleanups.push(
watchKeys(window.odoo),
watchKeys(window, ALLOWED_GLOBAL_KEYS),
watchListeners(window),
watchAddedNodes(window)
);
// Load module set modules (without entry point)
for (const name of this.moduleSet.moduleNames) {
const mockFactory = findMockFactory(name);
if (mockFactory) {
// Use mock
this.factories.set(name, {
deps: [],
fn: mockFactory(name, this.factories.get(name)),
});
}
if (!this.modules.has(name)) {
// Run (or re-run) module factory
this.startModule(name);
}
}
}
/**
* @override
* @type {typeof loader["startModule"]}
*/
startModule(name) {
if (this.canAddModule(name)) {
return super.startModule(...arguments);
}
this.jobs.delete(name);
return null;
}
}
const ALLOWED_GLOBAL_KEYS = [
"ace", // Ace editor
// Bootstrap.js is voluntarily ignored as it is deprecated
"Chart", // Chart.js
"DiffMatchPatch", // Diff Match Patch
"DOMPurify", // DOMPurify
"FullCalendar", // Full Calendar
"L", // Leaflet
"lamejs", // LameJS
"luxon", // Luxon
"odoo", // Odoo global object
"owl", // Owl
"pdfjsLib", // PDF JS
"Popper", // Popper
"Prism", // PrismJS
"SignaturePad", // Signature Pad
"StackTrace", // StackTrace
"ZXing", // ZXing
];
const AUTO_INCLUDED_ADDONS = {
/**
* spreadsheet addons defines a module that does not starts with `@spreadsheet` but `@odoo` (`@odoo/o-spreadsheet)
* To ensure that this module is loaded, we have to include `odoo` in the dependencies
*/
spreadsheet: ["odoo"],
/**
* Add all view types by default
*/
web_enterprise: ["web_gantt", "web_grid", "web_map"],
};
const CSRF_TOKEN = odoo.csrf_token;
const DEFAULT_ADDONS = ["base", "web"];
const MODULE_MOCKS_BY_NAME = new Map([
// Fixed modules
["@web/core/template_inheritance", makeFixedFactory],
// Other mocks
["@web/core/browser/browser", mockBrowserFactory],
["@web/core/currency", mockCurrencyFactory],
["@web/core/templates", makeTemplateFactory],
["@web/core/user", mockUserFactory],
["@web/session", mockSessionFactory],
]);
const MODULE_MOCKS_BY_REGEX = new Map([
// Fixed modules
[/\.bundle\.xml$/, makeFixedFactory],
]);
const R_DEFAULT_MODULE = /^@odoo\/(owl|hoot)/;
const R_PATH_ADDON = /^[@/]?(\w+)/;
const TEMPLATE_MODULE_NAME = "@web/core/templates";
/** @type {Record<string, string[]} */
const dependencies = {};
/** @type {Record<string, Deferred} */
const dependencyCache = {};
/** @type {Record<string, Promise<Response>} */
const globalFetchCache = Object.create(null);
/** @type {Set<string>} */
const modelsToFetch = new Set();
/** @type {Map<string, string[]>} */
const moduleNamesCache = new Map();
/** @type {Map<string, Record<string, unknown>>} */
const serverModelCache = new Map();
/** @type {string[]} */
const sortedModuleNames = [];
/** @type {string[]} */
let dependencyBatch = [];
/** @type {Promise<Record<string, string[]>> | null} */
let dependencyBatchPromise = null;
let nextRpcId = 1e9;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export function clearServerModelCache() {
serverModelCache.clear();
}
/**
* @param {Iterable<string>} modelNames
*/
export async function fetchModelDefinitions(modelNames) {
// Fetch missing definitions
const namesList = [...modelsToFetch];
if (namesList.length) {
const formData = new FormData();
formData.set("csrf_token", CSRF_TOKEN);
formData.set("model_names", JSON.stringify(namesList));
const response = await realFetch("/web/model/get_definitions", {
body: formData,
method: "POST",
});
if (!response.ok) {
const [s, some, does] =
namesList.length === 1 ? ["", "this", "does"] : ["s", "some or all of these", "do"];
const message = `Could not fetch definition${s} for server model${s} "${namesList.join(
`", "`
)}": ${some} model${s} ${does} not exist`;
throw new Error(message);
}
const modelDefs = await response.json();
for (const [modelName, modelDef] of Object.entries(modelDefs)) {
serverModelCache.set(modelName, freezeModel(modelDef));
modelsToFetch.delete(modelName);
}
}
const result = Object.create(null);
for (const modelName of modelNames) {
result[modelName] = unfreezeModel(serverModelCache.get(modelName));
}
return result;
}
/**
* @param {string | URL} input
* @param {RequestInit} [init]
*/
export function globalCachedFetch(input, init) {
if (init?.method && init.method.toLowerCase() !== "get") {
throw new Error(`cannot use a global cached fetch with HTTP method "${init.method}"`);
}
const key = String(input);
if (!(key in globalFetchCache)) {
globalFetchCache[key] = realFetch(input, init).catch((reason) => {
delete globalFetchCache[key];
throw reason;
});
}
return globalFetchCache[key].then((response) => response.clone());
}
/**
* @param {string} modelName
*/
export function registerModelToFetch(modelName) {
if (!serverModelCache.has(modelName)) {
modelsToFetch.add(modelName);
}
}
/**
* @param {{ fileSuffix?: string }} [options]
*/
export async function runTests(options) {
const { fileSuffix = "" } = options || {};
// Find dependency issues
const errors = loader.findErrors(loader.factories.keys());
delete errors.unloaded; // Only a few modules have been loaded yet => irrelevant
if (Object.keys(errors).length) {
return loader.reportErrors(errors);
}
// Sort modules to accelerate loading time
/** @type {Record<string, Deferred>} */
const defs = {};
/** @type {string[]} */
const testModuleNames = [];
for (const [name, { deps }] of loader.factories) {
// Register test module
if (name.endsWith(fileSuffix)) {
const baseName = name.slice(0, -fileSuffix.length);
testModuleNames.push(baseName);
}
// Register module dependencies
const [modDef, ...depDefs] = [name, ...deps].map((dep) => (defs[dep] ||= new Deferred()));
Promise.all(depDefs).then(() => {
sortedModuleNames.push(name);
modDef.resolve();
});
}
await Promise.all(Object.values(defs));
// Dry run
const { suites } = await dryRun(() => describeDrySuite(fileSuffix, testModuleNames));
// Run all test files
const filteredSuitePaths = new Set(suites.map((s) => s.fullName));
let currentAddonsKey = "";
for (const moduleName of testModuleNames) {
const suitePath = getSuitePath(moduleName);
if (!filteredSuitePaths.has(suitePath)) {
continue;
}
const moduleSet = await defineModuleSet(fileSuffix, [moduleName], new Set());
const moduleSetLoader = new ModuleSetLoader(moduleSet);
if (currentAddonsKey !== moduleSet.addonsKey) {
if (moduleSetLoader.modules.has(TEMPLATE_MODULE_NAME)) {
// If templates module is available: set URL filter to filter out
// static templates and cleanup current processed templates.
const templateModule = moduleSetLoader.modules.get(TEMPLATE_MODULE_NAME);
templateModule.setUrlFilters(moduleSet.filter ? [moduleSet.filter] : []);
templateModule.clearProcessedTemplates();
}
currentAddonsKey = moduleSet.addonsKey;
}
const suite = describe(suitePath, () => {
moduleSetLoader.setup();
moduleSetLoader.startModule(moduleName + fileSuffix);
});
// Run recently added tests
const running = await start(suite);
await moduleSetLoader.cleanup();
await __gcAndLogMemory(suite.fullName, suite.reporting.tests);
if (!running) {
break;
}
}
await stop();
// Perform final cleanups
moduleNamesCache.clear();
serverModelCache.clear();
clearObject(dependencies);
clearObject(dependencyCache);
clearObject(globalFetchCache);
const templateModule = loader.modules.get(TEMPLATE_MODULE_NAME);
if (templateModule) {
templateModule.setUrlFilters([]);
templateModule.clearProcessedTemplates();
}
await __gcAndLogMemory("tests done");
}

View file

@ -0,0 +1,27 @@
import { after } from "@odoo/hoot";
import { onTimeZoneChange } from "@odoo/hoot-mock";
import { patch } from "@web/core/utils/patch";
const { FixedOffsetZone, IANAZone, Settings } = luxon;
onTimeZoneChange((tz) => {
let defaultZone;
if (typeof tz === "string") {
defaultZone = IANAZone.create(tz);
} else {
const offset = new Date().getTimezoneOffset();
defaultZone = FixedOffsetZone.instance(-offset);
}
patchWithCleanup(Settings, { defaultZone });
});
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @type {typeof patch} */
export function patchWithCleanup(obj, patchValue) {
const unpatch = patch(obj, patchValue);
after(unpatch);
return unpatch;
}

View file

@ -0,0 +1,20 @@
import { afterEach, onError } from "@odoo/hoot";
export function preventResizeObserverError() {
let resizeObserverErrorCount = 0;
onError((ev) => {
// commits cb1fcb598f404bd4b0be3a541297cbdc556b29be and f478310d170028b99eb009560382e53330159200
// This error is sometimes thrown but is essentially harmless as long as it is not thrown
// indefinitely. cf https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
if (ev.message === "ResizeObserver loop completed with undelivered notifications.") {
if (resizeObserverErrorCount < 1) {
ev.preventDefault();
}
resizeObserverErrorCount++;
}
});
afterEach(() => {
resizeObserverErrorCount = 0;
});
}

View file

@ -0,0 +1,353 @@
import { queryAll, queryAllTexts, queryOne, queryText } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { findComponent, mountWithCleanup } from "./component_test_helpers";
import { contains } from "./dom_test_helpers";
import { getMockEnv, makeMockEnv } from "./env_test_helpers";
import { WithSearch } from "@web/search/with_search/with_search";
import { getDefaultConfig } from "@web/views/view";
const ensureSearchView = async () => {
if (
getMockEnv().isSmall &&
queryAll`.o_control_panel_navigation`.length &&
!queryAll`.o_searchview`.length
) {
await contains(`.o_control_panel_navigation button`).click();
}
};
const ensureSearchBarMenu = async () => {
if (!queryAll`.o_search_bar_menu`.length) {
await toggleSearchBarMenu();
}
};
/**
* 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;
}
}
//-----------------------------------------------------------------------------
// Search view
//-----------------------------------------------------------------------------
/**
* Mounts a component wrapped within a WithSearch.
*
* @template T
* @param {T} componentConstructor
* @param {Record<string, any>} [options]
* @param {Record<string, any>} [config]
* @returns {Promise<InstanceType<T>>}
*/
export async function mountWithSearch(componentConstructor, searchProps = {}, config = {}) {
class ComponentWithSearch extends Component {
static template = xml`
<WithSearch t-props="withSearchProps" t-slot-scope="search">
<t t-component="component" t-props="getProps(search)"/>
</WithSearch>
`;
static components = { WithSearch };
static props = ["*"];
setup() {
this.withSearchProps = searchProps;
this.component = componentConstructor;
}
getProps(search) {
const props = {
context: search.context,
domain: search.domain,
groupBy: search.groupBy,
orderBy: search.orderBy,
comparison: search.comparison,
display: search.display,
};
return filterPropsForComponent(componentConstructor, props);
}
}
const fullConfig = { ...getDefaultConfig(), ...config };
const env = await makeMockEnv({ config: fullConfig });
const root = await mountWithCleanup(ComponentWithSearch, { env });
return findComponent(root, (component) => component instanceof componentConstructor);
}
//-----------------------------------------------------------------------------
// Menu (generic)
//-----------------------------------------------------------------------------
/**
* @param {string} label
*/
export async function toggleMenu(label) {
await contains(`button.o-dropdown:contains(/^${label}$/i)`).click();
}
/**
* @param {string} label
*/
export async function toggleMenuItem(label) {
const target = queryOne`.o_menu_item:contains(/^${label}$/i)`;
if (target.classList.contains("dropdown-toggle")) {
await contains(target).hover();
} else {
await contains(target).click();
}
}
/**
* @param {string} itemLabel
* @param {string} optionLabel
*/
export async function toggleMenuItemOption(itemLabel, optionLabel) {
const { parentElement: root } = queryOne`.o_menu_item:contains(/^${itemLabel}$/i)`;
const target = queryOne(`.o_item_option:contains(/^${optionLabel}$/i)`, { root });
if (target.classList.contains("dropdown-toggle")) {
await contains(target).hover();
} else {
await contains(target).click();
}
}
/**
* @param {string} label
*/
export function isItemSelected(label) {
return queryOne`.o_menu_item:contains(/^${label}$/i)`.classList.contains("selected");
}
/**
* @param {string} itemLabel
* @param {string} optionLabel
*/
export function isOptionSelected(itemLabel, optionLabel) {
const { parentElement: root } = queryOne`.o_menu_item:contains(/^${itemLabel}$/i)`;
return queryOne(`.o_item_option:contains(/^${optionLabel}$/i)`, { root }).classList.contains(
"selected"
);
}
export function getMenuItemTexts() {
return queryAllTexts`.dropdown-menu .o_menu_item`;
}
export function getButtons() {
return queryAll`.o_control_panel_breadcrumbs button`;
}
export function getVisibleButtons() {
return queryAll`.o_control_panel_breadcrumbs button:visible, .o_control_panel_actions button:visible`;
}
//-----------------------------------------------------------------------------
// Filter menu
//-----------------------------------------------------------------------------
export async function toggleFilterMenu() {
await ensureSearchBarMenu();
await contains(`.o_filter_menu button.dropdown-toggle`).click();
}
export async function openAddCustomFilterDialog() {
await ensureSearchBarMenu();
await contains(`.o_filter_menu .o_menu_item.o_add_custom_filter`).click();
}
//-----------------------------------------------------------------------------
// Group by menu
//-----------------------------------------------------------------------------
export async function toggleGroupByMenu() {
await ensureSearchBarMenu();
await contains(`.o_group_by_menu .dropdown-toggle`).click();
}
/**
* @param {string} fieldName
*/
export async function selectGroup(fieldName) {
await ensureSearchBarMenu();
await contains(`.o_add_custom_group_menu`).select(fieldName);
}
//-----------------------------------------------------------------------------
// Favorite menu
//-----------------------------------------------------------------------------
export async function toggleFavoriteMenu() {
await ensureSearchBarMenu();
await contains(`.o_favorite_menu .dropdown-toggle`).click();
}
/**
* @param {string} text
*/
export async function deleteFavorite(text) {
await ensureSearchBarMenu();
await contains(`.o_favorite_menu .o_menu_item:contains(/^${text}$/i) i.fa-trash-o`).click();
}
export async function toggleSaveFavorite() {
await ensureSearchBarMenu();
await contains(`.o_favorite_menu .o_add_favorite`).click();
}
/**
* @param {string} name
*/
export async function editFavoriteName(name) {
await ensureSearchBarMenu();
await contains(
`.o_favorite_menu .o_add_favorite + .o_accordion_values input[type="text"]`
).edit(name, { confirm: false });
}
export async function saveFavorite() {
await ensureSearchBarMenu();
await contains(`.o_favorite_menu .o_add_favorite + .o_accordion_values button`).click();
}
//-----------------------------------------------------------------------------
// Comparison menu
//-----------------------------------------------------------------------------
export async function toggleComparisonMenu() {
await ensureSearchBarMenu();
await contains(`.o_comparison_menu button.dropdown-toggle`).click();
}
//-----------------------------------------------------------------------------
// Search bar
//-----------------------------------------------------------------------------
export function getFacetTexts() {
return queryAllTexts(`.o_searchview_facet`);
}
/**
* @param {string} label
*/
export async function removeFacet(label) {
await ensureSearchView();
await contains(`.o_searchview_facet:contains(/^${label}$/i) .o_facet_remove`).click();
}
/**
* @param {string} value
*/
export async function editSearch(value) {
await ensureSearchView();
await contains(`.o_searchview input`).edit(value, { confirm: false });
}
export async function validateSearch() {
await ensureSearchView();
await contains(`.o_searchview input`).press("Enter");
}
//-----------------------------------------------------------------------------
// Switch view
//-----------------------------------------------------------------------------
/**
* @param {import("./mock_server/mock_server").ViewType} viewType
*/
export async function switchView(viewType) {
await contains(`button.o_switch_view.o_${viewType}`).click();
}
//-----------------------------------------------------------------------------
// Pager
//-----------------------------------------------------------------------------
/**
* @param {HTMLElement} root
*/
export function getPagerValue(root) {
return queryText(".o_pager .o_pager_value", { root })
.split(/\s*-\s*/)
.map(Number);
}
/**
* @param {HTMLElement} root
*/
export function getPagerLimit(root) {
return parseInt(queryText(".o_pager .o_pager_limit", { root }), 10);
}
/**
* @param {HTMLElement} root
*/
export async function pagerNext(root) {
await contains(".o_pager button.o_pager_next", { root }).click();
}
/**
* @param {HTMLElement} root
*/
export async function pagerPrevious(root) {
await contains(".o_pager button.o_pager_previous", { root }).click();
}
/**
* @param {string} value
*/
export async function editPager(value) {
await contains(`.o_pager .o_pager_limit`).edit(value);
}
//-----------------------------------------------------------------------------
// Action Menu
//-----------------------------------------------------------------------------
/**
* @param {EventTarget} el
* @param {string} [menuFinder="Action"]
* @returns {Promise}
*/
export async function toggleActionMenu() {
await contains(".o_cp_action_menus .dropdown-toggle").click();
}
//-----------------------------------------------------------------------------
// Search bar menu
//-----------------------------------------------------------------------------
export async function toggleSearchBarMenu() {
await ensureSearchView();
await contains(`.o_searchview_dropdown_toggler`).click();
}

View file

@ -0,0 +1,51 @@
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
import { definePreset, defineTags, isHootReady } from "@odoo/hoot";
import { runTests } from "./module_set.hoot";
function beforeFocusRequired(test) {
if (!document.hasFocus()) {
console.warn(
"[FOCUS REQUIRED]",
`test "${test.name}" requires focus inside of the browser window and will probably fail without it`
);
}
}
definePreset("desktop", {
icon: "fa-desktop",
label: "Desktop",
platform: "linux",
size: [1366, 768],
tags: ["-mobile"],
touch: false,
});
definePreset("mobile", {
icon: "fa-mobile font-bold",
label: "Mobile",
platform: "android",
size: [375, 667],
tags: ["-desktop"],
touch: true,
});
defineTags(
{
name: "desktop",
exclude: ["headless", "mobile"],
},
{
name: "mobile",
exclude: ["desktop", "headless"],
},
{
name: "headless",
exclude: ["desktop", "mobile"],
},
{
name: "focus required",
before: beforeFocusRequired,
}
);
// Invoke tests after the interface has finished loading.
isHootReady.then(() => runTests({ fileSuffix: ".test" }));

View file

@ -0,0 +1,63 @@
import { queryFirst } from "@odoo/hoot-dom";
import { advanceTime } from "@odoo/hoot-mock";
import { contains } from "./dom_test_helpers";
/**
* @typedef {import("@odoo/hoot-dom").PointerOptions} PointerOptions
* @typedef {import("@odoo/hoot-dom").Target} Target
*/
/**
* @param {Target} target
* @param {number} direction
* @param {PointerOptions} [dragOptions]
* @param {PointerOptions} [moveToOptions]
* @returns {Promise<void>}
*/
async function swipe(target, direction, dragOptions, moveToOptions) {
const el = queryFirst(target);
if (direction < 0) {
// The scrollable element is set at its right limit
el.scrollLeft = el.scrollWidth - el.offsetWidth;
} else {
// The scrollable element is set at its left limit
el.scrollLeft = 0;
}
const { moveTo, drop } = await contains(el).drag({
position: { x: 0, y: 0 },
...dragOptions,
});
await moveTo(el, {
position: { x: direction * el.clientWidth },
...moveToOptions,
});
await drop();
await advanceTime(1000);
}
/**
* Will simulate a swipe left on the target element.
*
* @param {Target} target
* @param {PointerOptions} [dragOptions]
* @param {PointerOptions} [moveToOptions]
* @returns {Promise<void>}
*/
export async function swipeLeft(target, dragOptions, moveToOptions) {
await swipe(target, -1, dragOptions, moveToOptions);
}
/**
* Will simulate a swipe right on the target element.
*
* @param {Target} target
* @param {PointerOptions} [dragOptions]
* @param {PointerOptions} [moveToOptions]
* @returns {Promise<void>}
*/
export async function swipeRight(target, dragOptions, moveToOptions) {
await swipe(target, +1, dragOptions, moveToOptions);
}

View file

@ -0,0 +1,26 @@
import { after } from "@odoo/hoot";
import { serverState } from "./mock_server_state.hoot";
import { patchWithCleanup } from "./patch_test_helpers";
import { loadLanguages, translatedTerms, translationLoaded } from "@web/core/l10n/translation";
/**
* @param {Record<string, string>} languages
*/
export function installLanguages(languages) {
serverState.multiLang = true;
patchWithCleanup(loadLanguages, {
installedLanguages: Object.entries(languages),
});
}
/**
* @param {Record<string, string>} [terms]
*/
export function patchTranslations(terms = {}) {
translatedTerms[translationLoaded] = true;
after(() => {
translatedTerms[translationLoaded] = false;
});
patchWithCleanup(translatedTerms, terms);
}

View file

@ -0,0 +1,294 @@
import { after, expect, getFixture } from "@odoo/hoot";
import { click, formatXml, queryAll, queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame, Deferred, tick } from "@odoo/hoot-mock";
import { Component, onMounted, useSubEnv, xml } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { View } from "@web/views/view";
import { mountWithCleanup } from "./component_test_helpers";
import { contains } from "./dom_test_helpers";
import { getService } from "./env_test_helpers";
import { registerInlineViewArchs } from "./mock_server/mock_model";
/**
* @typedef {import("@web/views/view").Config} Config
*
* @typedef {ViewProps & {
* archs?: Record<string, string>
* config?: Config;
* env?: import("@web/env").OdooEnv;
* resId?: number;
* [key: string]: any;
* }} MountViewParams
*
* @typedef {{
* class?: string;
* id?: string;
* index?: number;
* modifier?: string;
* target?: string;
* text?: string;
* }} SelectorOptions
*
* @typedef {import("@odoo/hoot-dom").FormatXmlOptions} FormatXmlOptions
* @typedef {import("@web/views/view").ViewProps} ViewProps
* @typedef {import("./mock_server/mock_model").ViewType} ViewType
*/
//-----------------------------------------------------------------------------
// Internals
//-----------------------------------------------------------------------------
/**
* FIXME: isolate to external helper in @web?
*
* @param {unknown} value
*/
const isNil = (value) => value === null || value === undefined;
class ViewDialog extends Component {
static components = { Dialog, View };
static props = {
onMounted: Function,
viewEnv: Object,
viewProps: Object,
close: Function,
};
static template = xml`
<Dialog>
<View t-props="props.viewProps" />
</Dialog>
`;
setup() {
useSubEnv(this.props.viewEnv);
onMounted(() => this.props.onMounted());
}
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
*
* @param {string} base
* @param {SelectorOptions} [params]
*/
export function buildSelector(base, params) {
let selector = base;
params ||= {};
if (params.id) {
selector += `#${params.id}`;
}
if (params.class) {
selector += `.${params.class}`;
}
if (params.modifier) {
selector += `:${params.modifier}`;
}
if (params.text) {
selector += `:contains(${params.text})`;
}
if (!isNil(params.index)) {
selector += `:eq(${params.index})`;
}
if (params.target) {
selector += ` ${params.target}`;
}
return selector;
}
/**
* @param {SelectorOptions} [options]
*/
export async function clickButton(options) {
await contains(buildSelector(`.btn:enabled`, options)).click();
}
/**
* @param {SelectorOptions} [options]
*/
export async function clickCancel(options) {
await contains(buildSelector(`.o_form_button_cancel:enabled`, options)).click();
}
/**
* @param {string} fieldName
* @param {SelectorOptions} [options]
*/
export async function clickFieldDropdown(fieldName, options) {
await contains(buildSelector(`[name='${fieldName}'] .dropdown input`, options)).click();
}
/**
* @param {string} fieldName
* @param {string} itemContent
* @param {SelectorOptions} [options]
*/
export async function clickFieldDropdownItem(fieldName, itemContent, options) {
const dropdowns = queryAll(
buildSelector(`[name='${fieldName}'] .dropdown .dropdown-menu`, options)
);
if (dropdowns.length === 0) {
throw new Error(`No dropdown found for field ${fieldName}`);
} else if (dropdowns.length > 1) {
throw new Error(`Found ${dropdowns.length} dropdowns for field ${fieldName}`);
}
const dropdownItems = queryAll(buildSelector("li", options), { root: dropdowns[0] });
const indexToClick = queryAllTexts(dropdownItems).indexOf(itemContent);
if (indexToClick === -1) {
throw new Error(`The element '${itemContent}' does not exist in the dropdown`);
}
await click(dropdownItems[indexToClick]);
await animationFrame();
}
/**
* @param {SelectorOptions} [options]
*/
export async function clickModalButton(options) {
await contains(buildSelector(`.modal .btn:enabled`, options)).click();
}
/**
* @param {SelectorOptions} [options]
*/
export async function clickSave(options) {
await contains(buildSelector(`.o_form_button_save:enabled`, options)).click();
}
/**
* @param {SelectorOptions} [options]
*/
export async function clickViewButton(options) {
await contains(buildSelector(`.o_view_controller .btn:enabled`, options)).click();
}
/**
* @param {string} value
*/
export function expectMarkup(value) {
return {
/**
* @param {string} expected
* @param {FormatXmlOptions} [options]
*/
toBe(expected, options) {
expect(formatXml(value, options)).toBe(formatXml(expected, options));
},
};
}
/**
* @param {string} name
* @param {SelectorOptions} options
*/
export function fieldInput(name, options) {
return contains(buildSelector(`.o_field_widget[name='${name}'] input`, options));
}
/**
* @param {MountViewParams} params
*/
export async function mountViewInDialog(params) {
const container = await mountWithCleanup(MainComponentsContainer, {
env: params.env,
});
const deferred = new Deferred();
getService("dialog").add(ViewDialog, {
viewEnv: { config: params.config },
viewProps: parseViewProps(params),
onMounted() {
deferred.resolve();
},
});
await deferred;
return container;
}
/**
* @param {MountViewParams} params
* @param {HTMLElement} [target]
*/
export async function mountView(params, target = null) {
const actionManagerEl = document.createElement("div");
actionManagerEl.classList.add("o_action_manager");
(target ?? getFixture()).append(actionManagerEl);
after(() => actionManagerEl.remove());
return mountWithCleanup(View, {
env: params.env,
componentEnv: { config: params.config },
props: parseViewProps(params),
target: actionManagerEl,
});
}
/**
* @param {ViewProps & { archs?: Record<string, string> }} props
* @returns {ViewProps}
*/
export function parseViewProps(props) {
let className = "o_action";
if (props.className) {
className += " " + props.className;
}
const viewProps = { ...props, className };
if (
props.archs ||
!isNil(props.arch) ||
!isNil(props.searchViewArch) ||
!isNil(props.searchViewId) ||
!isNil(props.viewId)
) {
viewProps.viewId ??= -1;
viewProps.searchViewId ??= -1;
registerInlineViewArchs(viewProps.resModel, {
...props.archs,
[[viewProps.type, viewProps.viewId]]: viewProps.arch,
[["search", viewProps.searchViewId]]: viewProps.searchViewArch,
});
} else {
// Force `get_views` call
viewProps.viewId = false;
viewProps.searchViewId = false;
}
delete viewProps.arch;
delete viewProps.archs;
delete viewProps.config;
delete viewProps.searchViewArch;
return viewProps;
}
/**
* Open a field dropdown and click on the item which matches the
* given content
* @param {string} fieldName
* @param {string} itemContent
* @param {SelectorOptions} [options]
*/
export async function selectFieldDropdownItem(fieldName, itemContent, options) {
await clickFieldDropdown(fieldName, options);
await clickFieldDropdownItem(fieldName, itemContent);
}
/**
* Emulates the behaviour when we hide the tab in the browser.
*/
export async function hideTab() {
const prop = Object.getOwnPropertyDescriptor(Document.prototype, "visibilityState");
Object.defineProperty(document, "visibilityState", {
value: "hidden",
configurable: true,
writable: true,
});
document.dispatchEvent(new Event("visibilitychange"));
await tick();
Object.defineProperty(document, "visibilityState", prop);
}

View file

@ -0,0 +1,37 @@
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { WebClient } from "@web/webclient/webclient";
import { mountWithCleanup } from "./component_test_helpers";
class TestClientAction extends Component {
static template = xml`
<div class="test_client_action">
ClientAction_<t t-esc="props.action.params?.description"/>
</div>`;
static props = ["*"];
}
export function useTestClientAction() {
const tag = "__test__client__action__";
registry.category("actions").add(tag, TestClientAction);
return {
tag,
target: "main",
type: "ir.actions.client",
params: { description: "Id 1" },
};
}
/**
* @param {Parameters<typeof mountWithCleanup>[1]} [options]
*/
export async function mountWebClient(options) {
await mountWithCleanup(WebClient, options);
// Wait for visual changes caused by a potential loadState
await animationFrame();
// wait for BlankComponent
await animationFrame();
// wait for the regular rendering
await animationFrame();
}

View file

@ -0,0 +1,678 @@
/** @odoo-module alias=@web/../tests/mobile/core/action_swiper_tests default=false */
import { beforeEach, expect, test } from "@odoo/hoot";
import { hover, queryFirst } from "@odoo/hoot-dom";
import { advanceTime, animationFrame, mockTouch } from "@odoo/hoot-mock";
import { Component, onPatched, xml } from "@odoo/owl";
import {
contains,
defineParams,
mountWithCleanup,
patchWithCleanup,
swipeLeft,
swipeRight,
} from "@web/../tests/web_test_helpers";
import { ActionSwiper } from "@web/core/action_swiper/action_swiper";
import { Deferred } from "@web/core/utils/concurrency";
beforeEach(() => mockTouch(true));
// Tests marked as will fail on browsers that don't support
// TouchEvent by default. It might be an option to activate on some browser.
test("render only its target if no props is given", async () => {
class Parent extends Component {
static props = ["*"];
static components = { ActionSwiper };
static template = xml`
<div class="d-flex">
<ActionSwiper>
<div class="target-component"/>
</ActionSwiper>
</div>
`;
}
await mountWithCleanup(Parent);
expect("div.o_actionswiper").toHaveCount(0);
expect("div.target-component").toHaveCount(1);
});
test("only render the necessary divs", async () => {
await mountWithCleanup(ActionSwiper, {
props: {
onRightSwipe: {
action: () => {},
icon: "fa-circle",
bgColor: "bg-warning",
},
slots: {},
},
});
expect("div.o_actionswiper_right_swipe_area").toHaveCount(1);
expect("div.o_actionswiper_left_swipe_area").toHaveCount(0);
await mountWithCleanup(ActionSwiper, {
props: {
onLeftSwipe: {
action: () => {},
icon: "fa-circle",
bgColor: "bg-warning",
},
slots: {},
},
});
expect("div.o_actionswiper_right_swipe_area").toHaveCount(1);
expect("div.o_actionswiper_left_swipe_area").toHaveCount(1);
});
test("render with the height of its content", async () => {
class Parent extends Component {
static props = ["*"];
static components = { ActionSwiper };
static template = xml`
<div class="o-container d-flex" style="width: 200px; height: 200px; overflow: auto">
<ActionSwiper onRightSwipe = "{
action: () => this.onRightSwipe(),
icon: 'fa-circle',
bgColor: 'bg-warning'
}">
<div class="target-component" style="height: 800px">This element is very high and
the o-container element must have a scrollbar</div>
</ActionSwiper>
</div>
`;
onRightSwipe() {
expect.step("onRightSwipe");
}
}
await mountWithCleanup(Parent);
expect(queryFirst(".o_actionswiper").scrollHeight).toBe(
queryFirst(".target-component").scrollHeight,
{ message: "the swiper has the height of its content" }
);
expect(queryFirst(".o_actionswiper").scrollHeight).toBeGreaterThan(
queryFirst(".o_actionswiper").clientHeight,
{ message: "the height of the swiper must make the parent div scrollable" }
);
});
test("can perform actions by swiping to the right", async () => {
class Parent extends Component {
static props = ["*"];
static components = { ActionSwiper };
static template = xml`
<div class="d-flex">
<ActionSwiper onRightSwipe = "{
action: () => this.onRightSwipe(),
icon: 'fa-circle',
bgColor: 'bg-warning'
}">
<div class="target-component" style="width: 200px; height: 80px">Test</div>
</ActionSwiper>
</div>
`;
onRightSwipe() {
expect.step("onRightSwipe");
}
}
await mountWithCleanup(Parent);
const swiper = queryFirst(".o_actionswiper");
const targetContainer = queryFirst(".o_actionswiper_target_container");
const dragHelper = await contains(swiper).drag({
position: {
clientX: 0,
clientY: 0,
},
});
await dragHelper.moveTo(swiper, {
position: {
clientX: (3 * swiper.clientWidth) / 4,
clientY: 0,
},
});
expect(targetContainer.style.transform).toInclude("translateX", {
message: "target has translateX",
});
// Touch ends before the half of the distance has been reached
await dragHelper.moveTo(swiper, {
position: {
clientX: swiper.clientWidth / 2 - 1,
clientY: 0,
},
});
await dragHelper.drop();
await animationFrame();
expect(targetContainer.style.transform).not.toInclude("translateX", {
message: "target does not have a translate value",
});
// Touch ends once the half of the distance has been crossed
await swipeRight(".o_actionswiper");
// The action is performed AND the component is reset
expect(targetContainer.style.transform).not.toInclude("translateX", {
message: "target does not have a translate value",
});
expect.verifySteps(["onRightSwipe"]);
});
test("can perform actions by swiping in both directions", async () => {
expect.assertions(5);
class Parent extends Component {
static props = ["*"];
static components = { ActionSwiper };
static template = xml`
<div class="d-flex">
<ActionSwiper
onRightSwipe = "{
action: () => this.onRightSwipe(),
icon: 'fa-circle',
bgColor: 'bg-warning'
}"
onLeftSwipe = "{
action: () => this.onLeftSwipe(),
icon: 'fa-check',
bgColor: 'bg-success'
}">
<div class="target-component" style="width: 250px; height: 80px">Swipe in both directions</div>
</ActionSwiper>
</div>
`;
onRightSwipe() {
expect.step("onRightSwipe");
}
onLeftSwipe() {
expect.step("onLeftSwipe");
}
}
await mountWithCleanup(Parent);
const swiper = queryFirst(".o_actionswiper");
const targetContainer = queryFirst(".o_actionswiper_target_container");
const dragHelper = await contains(swiper).drag({
position: {
clientX: 0,
clientY: 0,
},
});
await dragHelper.moveTo(swiper, {
position: {
clientX: (3 * swiper.clientWidth) / 4,
clientY: 0,
},
});
expect(targetContainer.style.transform).toInclude("translateX", {
message: "target has translateX",
});
// Touch ends before the half of the distance has been reached to the left
await dragHelper.moveTo(swiper, {
position: {
clientX: -swiper.clientWidth / 2 + 1,
clientY: 0,
},
});
await dragHelper.drop();
expect(targetContainer.style.transform).not.toInclude("translateX", {
message: "target does not have a translate value",
});
// Touch ends once the half of the distance has been crossed to the left
await swipeLeft(".o_actionswiper");
expect.verifySteps(["onLeftSwipe"]);
// Touch ends once the half of the distance has been crossed to the right
await swipeRight(".o_actionswiper");
expect(targetContainer.style.transform).not.toInclude("translateX", {
message: "target doesn't have translateX after all actions are performed",
});
expect.verifySteps(["onRightSwipe"]);
});
test("invert the direction of swipes when language is rtl", async () => {
defineParams({
lang_parameters: {
direction: "rtl",
},
});
class Parent extends Component {
static props = ["*"];
static components = { ActionSwiper };
static template = xml`
<div class="d-flex">
<ActionSwiper
onRightSwipe = "{
action: () => this.onRightSwipe(),
icon: 'fa-circle',
bgColor: 'bg-warning'
}"
onLeftSwipe = "{
action: () => this.onLeftSwipe(),
icon: 'fa-check',
bgColor: 'bg-success'
}">
<div class="target-component" style="width: 250px; height: 80px">Swipe in both directions</div>
</ActionSwiper>
</div>
`;
onRightSwipe() {
expect.step("onRightSwipe");
}
onLeftSwipe() {
expect.step("onLeftSwipe");
}
}
await mountWithCleanup(Parent);
// Touch ends once the half of the distance has been crossed to the left
await swipeLeft(".o_actionswiper");
await advanceTime(500);
// In rtl languages, actions are permuted
expect.verifySteps(["onRightSwipe"]);
await swipeRight(".o_actionswiper");
await advanceTime(500);
// In rtl languages, actions are permuted
expect.verifySteps(["onLeftSwipe"]);
});
test("swiping when the swiper contains scrollable areas", async () => {
expect.assertions(7);
class Parent extends Component {
static props = ["*"];
static components = { ActionSwiper };
static template = xml`
<div class="d-flex">
<ActionSwiper
onRightSwipe = "{
action: () => this.onRightSwipe(),
icon: 'fa-circle',
bgColor: 'bg-warning'
}"
onLeftSwipe = "{
action: () => this.onLeftSwipe(),
icon: 'fa-check',
bgColor: 'bg-success'
}">
<div class="target-component" style="width: 200px; height: 300px">
<h1>Test about swiping and scrolling</h1>
<div class="large-content overflow-auto">
<h2>This div contains a larger element that will make it scrollable</h2>
<p class="large-text" style="width: 400px">This element is so large it needs to be scrollable</p>
</div>
</div>
</ActionSwiper>
</div>
`;
onRightSwipe() {
expect.step("onRightSwipe");
}
onLeftSwipe() {
expect.step("onLeftSwipe");
}
}
await mountWithCleanup(Parent);
const swiper = queryFirst(".o_actionswiper");
const targetContainer = queryFirst(".o_actionswiper_target_container");
const scrollable = queryFirst(".large-content");
const largeText = queryFirst(".large-text", { root: scrollable });
const clientYMiddleScrollBar = Math.floor(
scrollable.getBoundingClientRect().top + scrollable.getBoundingClientRect().height / 2
);
// The scrollable element is set as scrollable
scrollable.scrollLeft = 100;
let dragHelper = await contains(swiper).drag({
position: {
clientX: 0,
clientY: 0,
},
});
await dragHelper.moveTo(swiper, {
position: {
clientX: (3 * swiper.clientWidth) / 4,
clientY: 0,
},
});
expect(targetContainer.style.transform).toInclude("translateX", {
message: "the swiper can swipe if the scrollable area is not under touch pressure",
});
await dragHelper.moveTo(swiper, {
position: {
clientX: 0,
clientY: 0,
},
});
await dragHelper.drop();
dragHelper = await contains(largeText).drag({
position: {
clientX: scrollable.clientLeft,
clientY: clientYMiddleScrollBar,
},
});
await dragHelper.moveTo(largeText, {
position: {
clientX: scrollable.clientWidth,
clientY: clientYMiddleScrollBar,
},
});
expect(targetContainer.style.transform).not.toInclude("translateX", {
message:
"the swiper has not swiped to the right because the scrollable element was scrollable to the left",
});
await dragHelper.drop();
// The scrollable element is set at its left limit
scrollable.scrollLeft = 0;
await hover(largeText, {
position: {
clientX: scrollable.clientLeft,
clientY: clientYMiddleScrollBar,
},
});
dragHelper = await contains(largeText).drag({
position: {
clientX: scrollable.clientLeft,
clientY: clientYMiddleScrollBar,
},
});
await dragHelper.moveTo(largeText, {
position: {
clientX: scrollable.clientWidth,
clientY: clientYMiddleScrollBar,
},
});
expect(targetContainer.style.transform).toInclude("translateX", {
message:
"the swiper has swiped to the right because the scrollable element couldn't scroll anymore to the left",
});
await dragHelper.drop();
await advanceTime(500);
expect.verifySteps(["onRightSwipe"]);
dragHelper = await contains(largeText).drag({
position: {
clientX: scrollable.clientWidth,
clientY: clientYMiddleScrollBar,
},
});
await dragHelper.moveTo(largeText, {
position: {
clientX: scrollable.clientLeft,
clientY: clientYMiddleScrollBar,
},
});
expect(targetContainer.style.transform).not.toInclude("translateX", {
message:
"the swiper has not swiped to the left because the scrollable element was scrollable to the right",
});
await dragHelper.drop();
// The scrollable element is set at its right limit
scrollable.scrollLeft = scrollable.scrollWidth - scrollable.getBoundingClientRect().right;
await hover(largeText, {
position: {
clientX: scrollable.clientWidth,
clientY: clientYMiddleScrollBar,
},
});
dragHelper = await contains(largeText).drag({
position: {
clientX: scrollable.clientWidth,
clientY: clientYMiddleScrollBar,
},
});
await dragHelper.moveTo(largeText, {
position: {
clientX: scrollable.clientLeft,
clientY: clientYMiddleScrollBar,
},
});
expect(targetContainer.style.transform).toInclude("translateX", {
message:
"the swiper has swiped to the left because the scrollable element couldn't scroll anymore to the right",
});
await dragHelper.drop();
await advanceTime(500);
expect.verifySteps(["onLeftSwipe"]);
});
test("preventing swipe on scrollable areas when language is rtl", async () => {
expect.assertions(6);
defineParams({
lang_parameters: {
direction: "rtl",
},
});
class Parent extends Component {
static props = ["*"];
static components = { ActionSwiper };
static template = xml`
<div class="d-flex">
<ActionSwiper
onRightSwipe="{
action: () => this.onRightSwipe(),
icon: 'fa-circle',
bgColor: 'bg-warning'
}"
onLeftSwipe="{
action: () => this.onLeftSwipe(),
icon: 'fa-check',
bgColor: 'bg-success'
}">
<div class="target-component" style="width: 200px; height: 300px">
<h1>Test about swiping and scrolling for rtl</h1>
<div class="large-content overflow-auto">
<h2>elballorcs ti ekam lliw taht tnemele regral a sniatnoc vid sihT</h2>
<p class="large-text" style="width: 400px">elballorcs eb ot sdeen ti egral os si tnemele sihT</p>
</div>
</div>
</ActionSwiper>
</div>
`;
onRightSwipe() {
expect.step("onRightSwipe");
}
onLeftSwipe() {
expect.step("onLeftSwipe");
}
}
await mountWithCleanup(Parent);
const targetContainer = queryFirst(".o_actionswiper_target_container");
const scrollable = queryFirst(".large-content");
const largeText = queryFirst(".large-text", { root: scrollable });
const scrollableMiddleClientY = Math.floor(
scrollable.getBoundingClientRect().top + scrollable.getBoundingClientRect().height / 2
);
// RIGHT => Left trigger
// The scrollable element is set as scrollable
scrollable.scrollLeft = 100;
let dragHelper = await contains(largeText).drag({
position: {
clientX: scrollable.clientLeft,
clientY: scrollableMiddleClientY,
},
});
await dragHelper.moveTo(largeText, {
position: {
clientX: scrollable.clientWidth,
clientY: scrollableMiddleClientY,
},
});
expect(targetContainer.style.transform).not.toInclude("translateX", {
message:
"the swiper has not swiped to the right because the scrollable element was scrollable to the left",
});
await dragHelper.drop();
// The scrollable element is set at its left limit
scrollable.scrollLeft = 0;
await hover(largeText, {
position: {
clientX: scrollable.clientLeft,
clientY: scrollableMiddleClientY,
},
});
dragHelper = await contains(largeText).drag({
position: {
clientX: scrollable.clientLeft,
clientY: scrollableMiddleClientY,
},
});
await dragHelper.moveTo(largeText, {
position: {
clientX: scrollable.clientWidth,
clientY: scrollableMiddleClientY,
},
});
expect(targetContainer.style.transform).toInclude("translateX", {
message:
"the swiper has swiped to the right because the scrollable element couldn't scroll anymore to the left",
});
await dragHelper.drop();
await advanceTime(500);
// In rtl languages, actions are permuted
expect.verifySteps(["onLeftSwipe"]);
// LEFT => RIGHT trigger
await hover(largeText, {
position: {
clientX: scrollable.clientWidth,
clientY: scrollableMiddleClientY,
},
});
dragHelper = await contains(largeText).drag({
position: {
clientX: scrollable.clientWidth,
clientY: scrollableMiddleClientY,
},
});
await dragHelper.moveTo(largeText, {
position: {
clientX: scrollable.clientLeft,
clientY: scrollableMiddleClientY,
},
});
expect(targetContainer.style.transform).not.toInclude("translateX", {
message:
"the swiper has not swiped to the left because the scrollable element was scrollable to the right",
});
await dragHelper.drop();
// The scrollable element is set at its right limit
scrollable.scrollLeft = scrollable.scrollWidth - scrollable.getBoundingClientRect().right;
await hover(largeText, {
position: {
clientX: scrollable.clientWidth,
clientY: scrollableMiddleClientY,
},
});
dragHelper = await contains(largeText).drag({
position: {
clientX: scrollable.clientWidth,
clientY: scrollableMiddleClientY,
},
});
await dragHelper.moveTo(largeText, {
position: {
clientX: scrollable.clientLeft,
clientY: scrollableMiddleClientY,
},
});
expect(targetContainer.style.transform).toInclude("translateX", {
message:
"the swiper has swiped to the left because the scrollable element couldn't scroll anymore to the right",
});
await dragHelper.drop();
await advanceTime(500);
// In rtl languages, actions are permuted
expect.verifySteps(["onRightSwipe"]);
});
test("swipeInvalid prop prevents swiping", async () => {
expect.assertions(2);
class Parent extends Component {
static props = ["*"];
static components = { ActionSwiper };
static template = xml`
<div class="d-flex">
<ActionSwiper onRightSwipe = "{
action: () => this.onRightSwipe(),
icon: 'fa-circle',
bgColor: 'bg-warning',
}" swipeInvalid = "swipeInvalid">
<div class="target-component" style="width: 200px; height: 80px">Test</div>
</ActionSwiper>
</div>
`;
onRightSwipe() {
expect.step("onRightSwipe");
}
swipeInvalid() {
expect.step("swipeInvalid");
return true;
}
}
await mountWithCleanup(Parent);
const targetContainer = queryFirst(".o_actionswiper_target_container");
// Touch ends once the half of the distance has been crossed
await swipeRight(".o_actionswiper");
expect(targetContainer.style.transform).not.toInclude("translateX", {
message: "target doesn't have translateX after action is performed",
});
expect.verifySteps(["swipeInvalid"]);
});
test("action should be done before a new render", async () => {
let executingAction = false;
const prom = new Deferred();
patchWithCleanup(ActionSwiper.prototype, {
setup() {
super.setup();
onPatched(() => {
if (executingAction) {
expect.step("ActionSwiper patched");
}
});
},
});
class Parent extends Component {
static props = [];
static components = { ActionSwiper };
static template = xml`
<div class="d-flex">
<ActionSwiper animationType="'forwards'" onRightSwipe = "{
action: () => this.onRightSwipe(),
icon: 'fa-circle',
bgColor: 'bg-warning',
}">
<span>test</span>
</ActionSwiper>
</div>
`;
async onRightSwipe() {
await animationFrame();
expect.step("action done");
prom.resolve();
}
}
await mountWithCleanup(Parent);
await swipeRight(".o_actionswiper");
executingAction = true;
await prom;
await animationFrame();
expect.verifySteps(["action done", "ActionSwiper patched"]);
});

View file

@ -0,0 +1,817 @@
import { expect, test } from "@odoo/hoot";
import {
isInViewPort,
isScrollable,
pointerDown,
pointerUp,
press,
queryAllAttributes,
queryAllTexts,
queryFirst,
queryOne,
queryRect,
} from "@odoo/hoot-dom";
import { Deferred, animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { Component, useState, xml } from "@odoo/owl";
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
/**
* Helper needed until `isInViewPort` also checks intermediate parent elements.
* This is to make sure an element is actually visible, not just "within
* viewport boundaries" but below or above a parent's scroll point.
*
* @param {import("@odoo/hoot-dom").Target} target
* @returns {boolean}
*/
function isInViewWithinScrollableY(target) {
const element = queryFirst(target);
let container = element.parentElement;
while (
container &&
(container.scrollHeight <= container.clientHeight ||
!["auto", "scroll"].includes(getComputedStyle(container).overflowY))
) {
container = container.parentElement;
}
if (!container) {
return isInViewPort(element);
}
const { x, y } = queryRect(element);
const { height: containerHeight, width: containerWidth } = queryRect(container);
return y > 0 && y < containerHeight && x > 0 && x < containerWidth;
}
test("can be rendered", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
static props = {};
}
await mountWithCleanup(Parent);
expect(".o-autocomplete").toHaveCount(1);
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
await contains(".o-autocomplete input").click();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
expect(queryAllTexts(".o-autocomplete--dropdown-item")).toEqual(["World", "Hello"]);
const dropdownItemIds = queryAllAttributes(".dropdown-item", "id");
expect(dropdownItemIds).toEqual(["autocomplete_0_0", "autocomplete_0_1"]);
expect(queryAllAttributes(".dropdown-item", "role")).toEqual(["option", "option"]);
expect(queryAllAttributes(".dropdown-item", "aria-selected")).toEqual(["true", "false"]);
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[0]);
});
test("select option", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<AutoComplete
value="state.value"
sources="sources"
onSelect="(option) => this.onSelect(option)"
/>
`;
static props = {};
setup() {
this.state = useState({
value: "Hello",
});
}
get sources() {
return [
{
options: [{ label: "World" }, { label: "Hello" }],
},
];
}
onSelect(option) {
this.state.value = option.label;
expect.step(option.label);
}
}
await mountWithCleanup(Parent);
expect(".o-autocomplete input").toHaveValue("Hello");
await contains(".o-autocomplete input").click();
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
expect(".o-autocomplete input").toHaveValue("World");
expect.verifySteps(["World"]);
await contains(".o-autocomplete input").click();
await contains(".o-autocomplete--dropdown-item:last").click();
expect(".o-autocomplete input").toHaveValue("Hello");
expect.verifySteps(["Hello"]);
});
test("autocomplete with resetOnSelect='true'", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<div>
<div class= "test_value" t-esc="state.value"/>
<AutoComplete
value="''"
sources="sources"
onSelect="(option) => this.onSelect(option)"
resetOnSelect="true"
/>
</div>
`;
static props = {};
setup() {
this.state = useState({
value: "Hello",
});
}
get sources() {
return [
{
options: [{ label: "World" }, { label: "Hello" }],
},
];
}
onSelect(option) {
this.state.value = option.label;
expect.step(option.label);
}
}
await mountWithCleanup(Parent);
expect(".test_value").toHaveText("Hello");
expect(".o-autocomplete input").toHaveValue("");
await contains(".o-autocomplete input").edit("Blip", { confirm: false });
await runAllTimers();
await contains(".o-autocomplete--dropdown-item:last").click();
expect(".test_value").toHaveText("Hello");
expect(".o-autocomplete input").toHaveValue("");
expect.verifySteps(["Hello"]);
});
test("open dropdown on input", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
static props = {};
}
await mountWithCleanup(Parent);
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
await contains(".o-autocomplete input").fill("a", { confirm: false });
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
});
test("cancel result on escape keydown", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
autoSelect="true"
/>
`;
static props = {};
}
await mountWithCleanup(Parent);
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
expect(".o-autocomplete input").toHaveValue("Hello");
await contains(".o-autocomplete input").click();
await contains(".o-autocomplete input").edit("H", { confirm: false });
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
await contains(".o-autocomplete input").press("Escape");
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
expect(".o-autocomplete input").toHaveValue("Hello");
});
test("select input text on first focus", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<AutoComplete value="'Bar'" sources="[{ options: [{ label: 'Bar' }] }]" onSelect="() => {}"/>
`;
static props = {};
}
await mountWithCleanup(Parent);
await contains(".o-autocomplete input").click();
await runAllTimers();
expect(getSelection().toString()).toBe("Bar");
});
test("scroll outside should cancel result", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<div class="autocomplete_container overflow-auto" style="max-height: 100px;">
<div style="height: 1000px;">
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
autoSelect="true"
/>
</div>
</div>
`;
static props = {};
}
await mountWithCleanup(Parent);
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
expect(".o-autocomplete input").toHaveValue("Hello");
await contains(".o-autocomplete input").click();
await contains(".o-autocomplete input").edit("H", { confirm: false });
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
await contains(".autocomplete_container").scroll({ top: 10 });
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
expect(".o-autocomplete input").toHaveValue("Hello");
});
test("scroll inside should keep dropdown open", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<div class="autocomplete_container overflow-auto" style="max-height: 100px;">
<div style="height: 1000px;">
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
</div>
</div>
`;
static props = {};
}
await mountWithCleanup(Parent);
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
await contains(".o-autocomplete input").click();
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
await contains(".o-autocomplete .dropdown-menu").scroll({ top: 10 });
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
});
test("losing focus should cancel result", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
autoSelect="true"
/>
`;
static props = {};
}
await mountWithCleanup(Parent);
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
expect(".o-autocomplete input").toHaveValue("Hello");
await contains(".o-autocomplete input").click();
await contains(".o-autocomplete input").edit("H", { confirm: false });
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
await contains(document.body).click();
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
expect(".o-autocomplete input").toHaveValue("Hello");
});
test("click out after clearing input", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
static props = {};
}
await mountWithCleanup(Parent);
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
expect(".o-autocomplete input").toHaveValue("Hello");
await contains(".o-autocomplete input").click();
await contains(".o-autocomplete input").clear({ confirm: false });
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
await contains(document.body).click();
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
expect(".o-autocomplete input").toHaveValue("");
});
test("open twice should not display previous results", async () => {
let def = new Deferred();
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<AutoComplete value="''" sources="sources" onSelect="() => {}"/>
`;
static props = {};
get sources() {
return [
{
async options(search) {
await def;
if (search === "A") {
return [{ label: "AB" }, { label: "AC" }];
}
return [{ label: "AB" }, { label: "AC" }, { label: "BC" }];
},
},
];
}
}
await mountWithCleanup(Parent);
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
await contains(".o-autocomplete input").click();
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
expect(".o-autocomplete--dropdown-item").toHaveCount(1);
expect(".o-autocomplete--dropdown-item .fa-spin").toHaveCount(1); // loading
def.resolve();
await animationFrame();
expect(".o-autocomplete--dropdown-item").toHaveCount(3);
expect(".fa-spin").toHaveCount(0);
def = new Deferred();
await contains(".o-autocomplete input").fill("A", { confirm: false });
await runAllTimers();
expect(".o-autocomplete--dropdown-item").toHaveCount(1);
expect(".o-autocomplete--dropdown-item .fa-spin").toHaveCount(1); // loading
def.resolve();
await runAllTimers();
expect(".o-autocomplete--dropdown-item").toHaveCount(2);
expect(".fa-spin").toHaveCount(0);
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
// re-open the dropdown -> should not display the previous results
def = new Deferred();
await contains(".o-autocomplete input").click();
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
expect(".o-autocomplete--dropdown-item").toHaveCount(1);
expect(".o-autocomplete--dropdown-item .fa-spin").toHaveCount(1); // loading
});
test("press enter on autocomplete with empty source", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`<AutoComplete value="''" sources="sources" onSelect="onSelect"/>`;
static props = {};
get sources() {
return [{ options: [] }];
}
onSelect() {}
}
await mountWithCleanup(Parent);
expect(".o-autocomplete input").toHaveCount(1);
expect(".o-autocomplete input").toHaveValue("");
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
// click inside the input and press "enter", because why not
await contains(".o-autocomplete input").click();
await runAllTimers();
await contains(".o-autocomplete input").press("Enter");
expect(".o-autocomplete input").toHaveCount(1);
expect(".o-autocomplete input").toHaveValue("");
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
});
test("press enter on autocomplete with empty source (2)", async () => {
// in this test, the source isn't empty at some point, but becomes empty as the user
// updates the input's value.
class Parent extends Component {
static components = { AutoComplete };
static template = xml`<AutoComplete value="''" sources="sources" onSelect="onSelect"/>`;
static props = {};
get sources() {
const options = (val) => {
if (val.length > 2) {
return [{ label: "test A" }, { label: "test B" }, { label: "test C" }];
}
return [];
};
return [{ options }];
}
onSelect() {}
}
await mountWithCleanup(Parent);
expect(".o-autocomplete input").toHaveCount(1);
expect(".o-autocomplete input").toHaveValue("");
await contains(".o-autocomplete input").edit("test", { confirm: false });
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
expect(".o-autocomplete .dropdown-menu .o-autocomplete--dropdown-item").toHaveCount(3);
await contains(".o-autocomplete input").edit("t", { confirm: false });
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
await contains(".o-autocomplete input").press("Enter");
expect(".o-autocomplete input").toHaveCount(1);
expect(".o-autocomplete input").toHaveValue("t");
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
});
test.tags("desktop");
test("autofocus=true option work as expected", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<AutoComplete value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
autofocus="true"
onSelect="() => {}"
/>
`;
static props = {};
}
await mountWithCleanup(Parent);
expect(".o-autocomplete input").toBeFocused();
});
test.tags("desktop");
test("autocomplete in edition keep edited value before select option", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<button class="myButton" t-on-mouseover="onHover">My button</button>
<AutoComplete value="this.state.value"
sources="[{ options: [{ label: 'My Selection' }] }]"
onSelect.bind="onSelect"
/>
`;
static props = {};
setup() {
this.state = useState({ value: "Hello" });
}
onHover() {
this.state.value = "My Click";
}
onSelect() {
this.state.value = "My Selection";
}
}
await mountWithCleanup(Parent);
await contains(".o-autocomplete input").edit("Yolo", { confirm: false });
await runAllTimers();
expect(".o-autocomplete input").toHaveValue("Yolo");
// We want to simulate an external value edition (like a delayed onChange)
await contains(".myButton").hover();
expect(".o-autocomplete input").toHaveValue("Yolo");
// Leave inEdition mode when selecting an option
await contains(".o-autocomplete input").click();
await runAllTimers();
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
expect(".o-autocomplete input").toHaveValue("My Selection");
// Will also trigger the hover event
await contains(".myButton").click();
expect(".o-autocomplete input").toHaveValue("My Click");
});
test.tags("desktop");
test("autocomplete in edition keep edited value before blur", async () => {
let count = 0;
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<button class="myButton" t-on-mouseover="onHover">My button</button>
<AutoComplete value="this.state.value"
sources="[]"
onSelect="() => {}"
/>
`;
static props = {};
setup() {
this.state = useState({ value: "Hello" });
}
onHover() {
this.state.value = `My Click ${count++}`;
}
}
await mountWithCleanup(Parent);
await contains(".o-autocomplete input").edit("", { confirm: false });
await runAllTimers();
expect(".o-autocomplete input").toHaveValue("");
// We want to simulate an external value edition (like a delayed onChange)
await contains(".myButton").hover();
expect(".o-autocomplete input").toHaveValue("");
// Leave inEdition mode when blur the input
await contains(document.body).click();
expect(".o-autocomplete input").toHaveValue("");
// Will also trigger the hover event
await contains(".myButton").click();
expect(".o-autocomplete input").toHaveValue("My Click 1");
});
test("correct sequence of blur, focus and select", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`
<AutoComplete
value="state.value"
sources="sources"
onSelect.bind="onSelect"
onBlur.bind="onBlur"
onChange.bind="onChange"
autoSelect="true"
/>
`;
static props = {};
setup() {
this.state = useState({
value: "",
});
}
get sources() {
return [
{
options: [{ label: "World" }, { label: "Hello" }],
},
];
}
onChange() {
expect.step("change");
}
onSelect(option, params) {
queryOne(".o-autocomplete input").value = option.label;
expect.step("select " + option.label);
expect(params.triggeredOnBlur).not.toBe(true);
}
onBlur() {
expect.step("blur");
}
}
await mountWithCleanup(Parent);
expect(".o-autocomplete input").toHaveCount(1);
await contains(".o-autocomplete input").click();
// Navigate suggestions using arrow keys
let dropdownItemIds = queryAllAttributes(".dropdown-item", "id");
expect(dropdownItemIds).toEqual(["autocomplete_0_0", "autocomplete_0_1"]);
expect(queryAllAttributes(".dropdown-item", "role")).toEqual(["option", "option"]);
expect(queryAllAttributes(".dropdown-item", "aria-selected")).toEqual(["true", "false"]);
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[0]);
await contains(".o-autocomplete--input").press("ArrowDown");
dropdownItemIds = queryAllAttributes(".dropdown-item", "id");
expect(dropdownItemIds).toEqual(["autocomplete_0_0", "autocomplete_0_1"]);
expect(queryAllAttributes(".dropdown-item", "role")).toEqual(["option", "option"]);
expect(queryAllAttributes(".dropdown-item", "aria-selected")).toEqual(["false", "true"]);
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[1]);
// Start typing hello and click on the result
await contains(".o-autocomplete input").edit("h", { confirm: false });
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
await contains(".o-autocomplete--dropdown-item:last").click();
expect.verifySteps(["change", "select Hello"]);
expect(".o-autocomplete input").toBeFocused();
// Clear input and focus out
await contains(".o-autocomplete input").edit("", { confirm: false });
await runAllTimers();
await contains(document.body).click();
expect.verifySteps(["blur", "change"]);
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
});
test("autocomplete always closes on click away", async () => {
class Parent extends Component {
static template = xml`
<AutoComplete
value="state.value"
sources="sources"
onSelect.bind="onSelect"
autoSelect="true"
/>
`;
static props = ["*"];
static components = { AutoComplete };
setup() {
this.state = useState({
value: "",
});
}
get sources() {
return [
{
options: [{ label: "World" }, { label: "Hello" }],
},
];
}
onSelect(option) {
queryOne(".o-autocomplete--input").value = option.label;
}
}
await mountWithCleanup(Parent);
expect(".o-autocomplete input").toHaveCount(1);
await contains(".o-autocomplete input").click();
expect(".o-autocomplete--dropdown-item").toHaveCount(2);
await pointerDown(".o-autocomplete--dropdown-item:last");
await pointerUp(document.body);
expect(".o-autocomplete--dropdown-item").toHaveCount(2);
await contains(document.body).click();
expect(".o-autocomplete--dropdown-item").toHaveCount(0);
});
test("autocomplete trim spaces for search", async () => {
class Parent extends Component {
static template = xml`
<AutoComplete value="state.value" sources="sources" onSelect="() => {}"/>
`;
static props = ["*"];
static components = { AutoComplete };
setup() {
this.state = useState({ value: " World" });
}
get sources() {
return [
{
options(search) {
return [{ label: "World" }, { label: "Hello" }].filter(({ label }) =>
label.startsWith(search)
);
},
},
];
}
}
await mountWithCleanup(Parent);
await contains(`.o-autocomplete input`).click();
expect(queryAllTexts(`.o-autocomplete--dropdown-item`)).toEqual(["World", "Hello"]);
});
test("tab and shift+tab close the dropdown", async () => {
class Parent extends Component {
static template = xml`
<AutoComplete value="state.value" sources="sources" onSelect="() => {}"/>
`;
static props = ["*"];
static components = { AutoComplete };
setup() {
this.state = useState({ value: "" });
}
get sources() {
return [
{
options: [{ label: "World" }, { label: "Hello" }],
},
];
}
}
await mountWithCleanup(Parent);
const input = ".o-autocomplete input";
const dropdown = ".o-autocomplete--dropdown-menu";
expect(input).toHaveCount(1);
// Tab
await contains(input).click();
expect(dropdown).toBeVisible();
await press("Tab");
await animationFrame();
expect(dropdown).not.toHaveCount();
// Shift + Tab
await contains(input).click();
expect(dropdown).toBeVisible();
await press("Tab", { shiftKey: true });
await animationFrame();
expect(dropdown).not.toHaveCount();
});
test("autocomplete scrolls when moving with arrows", async () => {
class Parent extends Component {
static template = xml`
<style>
.o-autocomplete--dropdown-menu {
max-height: 100px;
}
</style>
<AutoComplete
value="state.value"
sources="sources"
onSelect="() => {}"
autoSelect="true"
/>
`;
static props = ["*"];
static components = { AutoComplete };
setup() {
this.state = useState({
value: "",
});
}
get sources() {
return [
{
options: [
{ label: "Never" },
{ label: "Gonna" },
{ label: "Give" },
{ label: "You" },
{ label: "Up" },
],
},
];
}
}
const dropdownSelector = ".o-autocomplete--dropdown-menu";
const activeItemSelector = ".o-autocomplete--dropdown-item .ui-state-active";
const msgInView = "active item should be in view within dropdown";
const msgNotInView = "item should not be in view within dropdown";
await mountWithCleanup(Parent);
expect(".o-autocomplete input").toHaveCount(1);
// Open with arrow key.
await contains(".o-autocomplete input").focus();
await press("ArrowDown");
await animationFrame();
expect(".o-autocomplete--dropdown-item").toHaveCount(5);
expect(isScrollable(dropdownSelector)).toBe(true, { message: "dropdown should be scrollable" });
// First element focused and visible (dropdown is not scrolled yet).
expect(".o-autocomplete--dropdown-item:first-child a").toHaveClass("ui-state-active");
expect(isInViewWithinScrollableY(activeItemSelector)).toBe(true, { message: msgInView });
// Navigate with the arrow keys. Go to the last item.
expect(isInViewWithinScrollableY(".o-autocomplete--dropdown-item:contains('Up')")).toBe(false, {
message: "'Up' " + msgNotInView,
});
await press("ArrowUp");
await press("ArrowUp");
await animationFrame();
expect(activeItemSelector).toHaveText("Up");
expect(isInViewWithinScrollableY(activeItemSelector)).toBe(true, { message: msgInView });
// Navigate to an item that is not currently visible.
expect(isInViewWithinScrollableY(".o-autocomplete--dropdown-item:contains('Never')")).toBe(
false,
{ message: "'Never' " + msgNotInView }
);
for (let i = 0; i < 4; i++) {
await press("ArrowUp");
}
await animationFrame();
expect(activeItemSelector).toHaveText("Never");
expect(isInViewWithinScrollableY(activeItemSelector)).toBe(true, { message: msgInView });
expect(isInViewWithinScrollableY(".o-autocomplete--dropdown-item:last")).toBe(false, {
message: "last " + msgNotInView,
});
});

View file

@ -0,0 +1,54 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { getService, makeMockEnv } from "@web/../tests/web_test_helpers";
describe.current.tags("headless");
let titleService;
beforeEach(async () => {
await makeMockEnv();
titleService = getService("title");
});
test("simple title", () => {
titleService.setParts({ one: "MyOdoo" });
expect(titleService.current).toBe("MyOdoo");
});
test("add title part", () => {
titleService.setParts({ one: "MyOdoo", two: null });
expect(titleService.current).toBe("MyOdoo");
titleService.setParts({ three: "Import" });
expect(titleService.current).toBe("MyOdoo - Import");
});
test("modify title part", () => {
titleService.setParts({ one: "MyOdoo" });
expect(titleService.current).toBe("MyOdoo");
titleService.setParts({ one: "Zopenerp" });
expect(titleService.current).toBe("Zopenerp");
});
test("delete title part", () => {
titleService.setParts({ one: "MyOdoo" });
expect(titleService.current).toBe("MyOdoo");
titleService.setParts({ one: null });
expect(titleService.current).toBe("Odoo");
});
test("all at once", () => {
titleService.setParts({ one: "MyOdoo", two: "Import" });
expect(titleService.current).toBe("MyOdoo - Import");
titleService.setParts({ one: "Zopenerp", two: null, three: "Sauron" });
expect(titleService.current).toBe("Zopenerp - Sauron");
});
test("get title parts", () => {
expect(titleService.current).toBe("");
titleService.setParts({ one: "MyOdoo", two: "Import" });
expect(titleService.current).toBe("MyOdoo - Import");
const parts = titleService.getParts();
expect(parts).toEqual({ one: "MyOdoo", two: "Import" });
parts.action = "Export";
expect(titleService.current).toBe("MyOdoo - Import"); // parts is a copy!
});

View file

@ -0,0 +1,85 @@
import { describe, expect, test } from "@odoo/hoot";
import { Deferred } from "@odoo/hoot-mock";
import { Cache } from "@web/core/utils/cache";
describe.current.tags("headless");
test("do not call getValue if already cached", () => {
const cache = new Cache((key) => {
expect.step(key);
return key.toUpperCase();
});
expect(cache.read("a")).toBe("A");
expect(cache.read("b")).toBe("B");
expect(cache.read("a")).toBe("A");
expect.verifySteps(["a", "b"]);
});
test("multiple cache key", async () => {
const cache = new Cache((...keys) => expect.step(keys.join("-")));
cache.read("a", 1);
cache.read("a", 2);
cache.read("a", 1);
expect.verifySteps(["a-1", "a-2"]);
});
test("compute key", async () => {
const cache = new Cache(
(key) => expect.step(key),
(key) => key.toLowerCase()
);
cache.read("a");
cache.read("A");
expect.verifySteps(["a"]);
});
test("cache promise", async () => {
const cache = new Cache((key) => {
expect.step(`read ${key}`);
return new Deferred();
});
cache.read("a").then((k) => expect.step(`then ${k}`));
cache.read("b").then((k) => expect.step(`then ${k}`));
cache.read("a").then((k) => expect.step(`then ${k}`));
cache.read("a").resolve("a");
cache.read("b").resolve("b");
await Promise.resolve();
expect.verifySteps(["read a", "read b", "then a", "then a", "then b"]);
});
test("clear cache", async () => {
const cache = new Cache((key) => expect.step(key));
cache.read("a");
cache.read("b");
expect.verifySteps(["a", "b"]);
cache.read("a");
cache.read("b");
expect.verifySteps([]);
cache.clear("a");
cache.read("a");
cache.read("b");
expect.verifySteps(["a"]);
cache.clear();
cache.read("a");
cache.read("b");
expect.verifySteps([]);
cache.invalidate();
cache.read("a");
cache.read("b");
expect.verifySteps(["a", "b"]);
});

View file

@ -0,0 +1,135 @@
import { expect, test } from "@odoo/hoot";
import { check, uncheck } from "@odoo/hoot-dom";
import { Component, useState, xml } from "@odoo/owl";
import { contains, defineParams, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { CheckBox } from "@web/core/checkbox/checkbox";
test("can be rendered", async () => {
await mountWithCleanup(CheckBox);
expect(`.o-checkbox input[type=checkbox]`).toHaveCount(1);
expect(`.o-checkbox input[type=checkbox]`).toBeEnabled();
});
test("has a slot for translatable text", async () => {
defineParams({ translations: { ragabadabadaba: "rugubudubudubu" } });
class Parent extends Component {
static components = { CheckBox };
static props = {};
static template = xml`<CheckBox>ragabadabadaba</CheckBox>`;
}
await mountWithCleanup(Parent);
expect(`.form-check`).toHaveCount(1);
expect(`.form-check`).toHaveText("rugubudubudubu", { exact: true });
});
test("call onChange prop when some change occurs", async () => {
let value = false;
class Parent extends Component {
static components = { CheckBox };
static props = {};
static template = xml`<CheckBox onChange="onChange" />`;
onChange(checked) {
value = checked;
}
}
await mountWithCleanup(Parent);
expect(`.o-checkbox input`).toHaveCount(1);
await check("input");
expect(value).toBe(true);
await uncheck("input");
expect(value).toBe(false);
});
test("checkbox with props disabled", async () => {
class Parent extends Component {
static components = { CheckBox };
static props = {};
static template = xml`<CheckBox disabled="true" />`;
}
await mountWithCleanup(Parent);
expect(`.o-checkbox input`).toHaveCount(1);
expect(`.o-checkbox input`).not.toBeEnabled();
});
test.tags("desktop");
test("can toggle value by pressing ENTER", async () => {
class Parent extends Component {
static components = { CheckBox };
static props = {};
static template = xml`<CheckBox onChange.bind="onChange" value="state.value" />`;
setup() {
this.state = useState({ value: false });
}
onChange(checked) {
this.state.value = checked;
}
}
await mountWithCleanup(Parent);
expect(`.o-checkbox input`).toHaveCount(1);
expect(`.o-checkbox input`).not.toBeChecked();
await contains(".o-checkbox input").press("Enter");
expect(`.o-checkbox input`).toBeChecked();
await contains(".o-checkbox input").press("Enter");
expect(`.o-checkbox input`).not.toBeChecked();
});
test.tags("desktop");
test("toggling through multiple ways", async () => {
class Parent extends Component {
static components = { CheckBox };
static props = {};
static template = xml`<CheckBox onChange.bind="onChange" value="state.value" />`;
setup() {
this.state = useState({ value: false });
}
onChange(checked) {
this.state.value = checked;
expect.step(String(checked));
}
}
await mountWithCleanup(Parent);
expect(`.o-checkbox input`).toHaveCount(1);
expect(`.o-checkbox input`).not.toBeChecked();
await contains(".o-checkbox").click();
expect(`.o-checkbox input`).toBeChecked();
await contains(".o-checkbox > .form-check-label", { visible: false }).uncheck();
expect(`.o-checkbox input`).not.toBeChecked();
await contains(".o-checkbox input").press("Enter");
expect(`.o-checkbox input`).toBeChecked();
await contains(".o-checkbox input").press(" ");
expect(`.o-checkbox input`).not.toBeChecked();
expect.verifySteps(["true", "false", "true", "false"]);
});

View file

@ -0,0 +1,354 @@
import { expect, test } from "@odoo/hoot";
import { queryAll, queryAllTexts, queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, markup, useState, xml } from "@odoo/owl";
import {
contains,
editAce,
mountWithCleanup,
patchWithCleanup,
preloadBundle,
preventResizeObserverError,
} from "@web/../tests/web_test_helpers";
import { CodeEditor } from "@web/core/code_editor/code_editor";
import { debounce } from "@web/core/utils/timing";
preloadBundle("web.ace_lib");
preventResizeObserverError();
function getDomValue() {
return queryAll(".ace_line")
.map((root) => queryAllTexts(`:scope > span`, { root }).join(""))
.join("\n");
}
function getFakeAceEditor() {
return {
session: {
on: () => {},
setMode: () => {},
setUseWorker: () => {},
setOptions: () => {},
getValue: () => {},
setValue: () => {},
},
renderer: {
setOptions: () => {},
$cursorLayer: { element: { style: {} } },
},
setOptions: () => {},
setValue: () => {},
getValue: () => "",
setTheme: () => {},
resize: () => {},
destroy: () => {},
setSession: () => {},
getSession() {
return this.session;
},
on: () => {},
};
}
/*
A custom implementation to dispatch keyboard events for ace specifically
It is very naive and simple, and could extended
FIXME: Specificities of Ace 1.32.3
-- Ace heavily relies on KeyboardEvent.keyCode, so hoot's helpers
cannot be used for this simple test.
-- Ace still relies on the keypress event
-- The textarea has no size in ace, it is a "hidden" input and a part of Ace's internals
hoot's helpers won't focus it naturally
-- The same Ace considers that if "Win" is not part of the useragent's string, we are in a MAC environment
So, instead of patching the useragent, we send to ace what it wants. (ie: Command + metaKey: true)
*/
function dispatchKeyboardEvents(el, tupleArray) {
for (const [evType, eventInit] of tupleArray) {
el.dispatchEvent(new KeyboardEvent(evType, { ...eventInit, bubbles: true }));
}
}
test("Can be rendered", async () => {
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor maxLines="10" mode="'xml'" />`;
static props = ["*"];
}
await mountWithCleanup(Parent);
expect(".ace_editor").toHaveCount(1);
});
test("CodeEditor shouldn't accepts markup values", async () => {
expect.errors(1);
patchWithCleanup(console, {
warn: (msg) => expect.step(msg),
});
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor value="props.value" />`;
static props = ["*"];
}
class GrandParent extends Component {
static components = { Parent };
static template = xml`<Parent value="state.value"/>`;
static props = ["*"];
setup() {
this.state = useState({ value: `<div>Some Text</div>` });
}
}
const codeEditor = await mountWithCleanup(GrandParent);
const textMarkup = markup("<div>Some Text</div>");
codeEditor.state.value = textMarkup;
await animationFrame();
expect.verifyErrors(["Invalid props for component 'CodeEditor': 'value' is not valid"]);
expect.verifySteps(["[Owl] Unhandled error. Destroying the root component"]);
});
test("onChange props called when code is edited", async () => {
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor maxLines="10" onChange.bind="onChange" />`;
static props = ["*"];
onChange(value) {
expect.step(value);
}
}
await mountWithCleanup(Parent);
await editAce("Some Text");
expect.verifySteps(["Some Text"]);
});
test("onChange props not called when value props is updated", async () => {
class Parent extends Component {
static components = { CodeEditor };
static template = xml`
<CodeEditor
value="state.value"
maxLines="10"
onChange.bind="onChange"
/>
`;
static props = ["*"];
state = useState({ value: "initial value" });
onChange(value) {
expect.step(value || "__emptystring__");
}
}
const parent = await mountWithCleanup(Parent);
expect(".ace_line").toHaveText("initial value");
parent.state.value = "new value";
await animationFrame();
await animationFrame();
expect(".ace_line").toHaveText("new value");
expect.verifySteps([]);
});
test("Default value correctly set and updates", async () => {
const textA = "<div>\n<p>A Paragraph</p>\n</div>";
const textB = "<div>\n<p>An Other Paragraph</p>\n</div>";
const textC = "<div>\n<p>A Paragraph</p>\n</div>\n<p>And More</p>";
class Parent extends Component {
static components = { CodeEditor };
static template = xml`
<CodeEditor
mode="'xml'"
value="state.value"
onChange.bind="onChange"
maxLines="200"
/>
`;
static props = ["*"];
setup() {
this.state = useState({ value: textA });
this.onChange = debounce(this.onChange.bind(this));
}
onChange(value) {
// Changing the value of the textarea manualy triggers an Ace "remove" event
// of the whole text (the value is thus empty), then an "add" event with the
// actual value, this isn't ideal but we ignore the remove.
if (value.length <= 0) {
return;
}
expect.step(value);
}
changeValue(newValue) {
this.state.value = newValue;
}
}
const codeEditor = await mountWithCleanup(Parent);
expect(getDomValue()).toBe(textA);
// Disable XML autocompletion for xml end tag.
// Necessary because the contains().edit() helpers triggers as if it was
// a real user interaction.
const ace_editor = window.ace.edit(queryOne(".ace_editor"));
ace_editor.setBehavioursEnabled(false);
const aceEditor = window.ace.edit(queryOne(".ace_editor"));
aceEditor.selectAll();
await editAce(textB);
expect(getDomValue()).toBe(textB);
codeEditor.changeValue(textC);
await animationFrame();
await animationFrame();
expect(getDomValue()).toBe(textC);
expect.verifySteps([textB]);
});
test("Mode props update imports the mode", async () => {
const fakeAceEditor = getFakeAceEditor();
fakeAceEditor.session.setMode = (mode) => {
expect.step(mode);
};
patchWithCleanup(window.ace, {
edit: () => fakeAceEditor,
});
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor maxLines="10" mode="state.mode" />`;
static props = ["*"];
setup() {
this.state = useState({ mode: "xml" });
}
setMode(newMode) {
this.state.mode = newMode;
}
}
const codeEditor = await mountWithCleanup(Parent);
expect.verifySteps(["ace/mode/xml"]);
await codeEditor.setMode("javascript");
await animationFrame();
expect.verifySteps(["ace/mode/javascript"]);
});
test("Theme props updates imports the theme", async () => {
const fakeAceEditor = getFakeAceEditor();
fakeAceEditor.setTheme = (theme) => {
expect.step(theme ? theme : "default");
};
patchWithCleanup(window.ace, {
edit: () => fakeAceEditor,
});
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor maxLines="10" theme="state.theme" />`;
static props = ["*"];
setup() {
this.state = useState({ theme: "" });
}
setTheme(newTheme) {
this.state.theme = newTheme;
}
}
const codeEditor = await mountWithCleanup(Parent);
expect.verifySteps(["default"]);
await codeEditor.setTheme("monokai");
await animationFrame();
expect.verifySteps(["ace/theme/monokai"]);
});
test("initial value cannot be undone", async () => {
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor mode="'xml'" value="'some value'" class="'h-100'" />`;
static props = ["*"];
}
await mountWithCleanup(Parent);
await animationFrame();
expect(".ace_editor").toHaveCount(1);
expect(".ace_editor .ace_content").toHaveText("some value");
const editor = window.ace.edit(queryOne(".ace_editor"));
const undo = editor.session.$undoManager.undo.bind(editor.session.$undoManager);
editor.session.$undoManager.undo = (...args) => {
expect.step("ace undo");
return undo(...args);
};
const aceContent = queryOne(".ace_editor textarea");
dispatchKeyboardEvents(aceContent, [
["keydown", { key: "Control", keyCode: 17 }],
["keypress", { key: "Control", keyCode: 17 }],
["keydown", { key: "z", keyCode: 90, ctrlKey: true }],
["keypress", { key: "z", keyCode: 90, ctrlKey: true }],
["keyup", { key: "z", keyCode: 90, ctrlKey: true }],
["keyup", { key: "Control", keyCode: 17 }],
]);
await animationFrame();
expect(".ace_editor .ace_content").toHaveText("some value");
expect.verifySteps(["ace undo"]);
});
test("code editor can take an initial cursor position", async () => {
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor maxLines="2" value="value" initialCursorPosition="initialPosition" onChange="onChange"/>`;
static props = ["*"];
setup() {
this.value = `
1
2
3
4aa
5
`.replace(/^\s*/gm, ""); // simple dedent
this.initialPosition = { row: 3, column: 2 };
}
onChange(value, startPosition) {
expect.step({ value, startPosition });
}
}
await mountWithCleanup(Parent);
await animationFrame();
const editor = window.ace.edit(queryOne(".ace_editor"));
expect(document.activeElement).toBe(editor.textInput.getElement());
expect(editor.getCursorPosition()).toEqual({ row: 3, column: 2 });
expect(queryAllTexts(".ace_gutter-cell")).toEqual(["3", "4", "5"]);
expect.verifySteps([]);
await contains(".ace_editor textarea", { displayed: true, visible: false }).edit("new\nvalue", {
instantly: true,
});
expect.verifySteps([
{
startPosition: {
column: 0,
row: 0,
},
value: "",
},
{
startPosition: {
column: 0,
row: 0,
},
value: "new\nvalue",
},
]);
});

View file

@ -0,0 +1,82 @@
import { expect, test } from "@odoo/hoot";
import { Component, xml } from "@odoo/owl";
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { ColorList } from "@web/core/colorlist/colorlist";
class Parent extends Component {
static template = xml`
<t t-component="Component" t-props="componentProps"/>
<div class="outsideDiv">Outside div</div>
`;
static props = ["*"];
get Component() {
return this.props.Component || ColorList;
}
get componentProps() {
const props = { ...this.props };
delete props.Component;
if (!props.onColorSelected) {
props.onColorSelected = () => {};
}
return props;
}
}
test("basic rendering with forceExpanded props", async () => {
await mountWithCleanup(Parent, {
props: {
colors: [0, 9],
forceExpanded: true,
},
});
expect(".o_colorlist").toHaveCount(1);
expect(".o_colorlist button").toHaveCount(2);
expect(".o_colorlist button:eq(1)").toHaveAttribute("title", "Raspberry");
expect(".o_colorlist button:eq(1)").toHaveClass("o_colorlist_item_color_9");
});
test("color click does not open the list if canToggle props is not given", async () => {
const selectedColorId = 0;
await mountWithCleanup(Parent, {
props: {
colors: [4, 5, 6],
selectedColor: selectedColorId,
onColorSelected: (colorId) => expect.step("color #" + colorId + " is selected"),
},
});
expect(".o_colorlist").toHaveCount(1);
expect("button.o_colorlist_toggler").toHaveCount(1);
await contains(".o_colorlist").click();
expect("button.o_colorlist_toggler").toHaveCount(1);
});
test("open the list of colors if canToggle props is given", async function () {
const selectedColorId = 0;
await mountWithCleanup(Parent, {
props: {
canToggle: true,
colors: [4, 5, 6],
selectedColor: selectedColorId,
onColorSelected: (colorId) => expect.step("color #" + colorId + " is selected"),
},
});
expect(".o_colorlist").toHaveCount(1);
expect(".o_colorlist button").toHaveClass("o_colorlist_item_color_" + selectedColorId);
await contains(".o_colorlist button").click();
expect("button.o_colorlist_toggler").toHaveCount(0);
expect(".o_colorlist button").toHaveCount(3);
await contains(".outsideDiv").click();
expect(".o_colorlist button").toHaveCount(1);
expect("button.o_colorlist_toggler").toHaveCount(1);
await contains(".o_colorlist_toggler").click();
await contains(".o_colorlist button:eq(2)").click();
expect.verifySteps(["color #6 is selected"]);
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,141 @@
import { expect, test } from "@odoo/hoot";
import { press, queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import {
contains,
defineActions,
defineMenus,
getService,
mountWithCleanup,
useTestClientAction,
} from "@web/../tests/web_test_helpers";
import { Dialog } from "@web/core/dialog/dialog";
import { WebClient } from "@web/webclient/webclient";
defineMenus([
{ id: 0 }, // prevents auto-loading the first action
{ id: 1, name: "Contact", actionID: 1001 },
{
id: 2,
name: "Sales",
actionID: 1002,
appID: 2,
children: [
{
id: 3,
name: "Info",
appID: 2,
actionID: 1003,
},
{
id: 4,
name: "Report",
appID: 2,
actionID: 1004,
},
],
},
]);
const testAction = useTestClientAction();
defineActions([
{ ...testAction, id: 1001, params: { description: "Id 1" } },
{ ...testAction, id: 1003, params: { description: "Info" } },
{ ...testAction, id: 1004, params: { description: "Report" } },
]);
test.tags("desktop");
test("displays only apps if the search value is '/'", async () => {
await mountWithCleanup(WebClient);
expect(".o_menu_brand").toHaveCount(0);
await press(["control", "k"]);
await animationFrame();
await contains(".o_command_palette_search input").edit("/", { confirm: false });
await animationFrame();
expect(".o_command_palette").toHaveCount(1);
expect(".o_command_category").toHaveCount(1);
expect(".o_command").toHaveCount(2);
expect(queryAllTexts(".o_command_name")).toEqual(["Contact", "Sales"]);
});
test.tags("desktop");
test("displays apps and menu items if the search value is not only '/'", async () => {
await mountWithCleanup(WebClient);
await press(["control", "k"]);
await animationFrame();
await contains(".o_command_palette_search input").edit("/sal", { confirm: false });
await animationFrame();
expect(".o_command_palette").toHaveCount(1);
expect(".o_command").toHaveCount(3);
expect(queryAllTexts(".o_command_name")).toEqual(["Sales", "Sales / Info", "Sales / Report"]);
});
test.tags("desktop");
test("opens an app", async () => {
await mountWithCleanup(WebClient);
expect(".o_menu_brand").toHaveCount(0);
await press(["control", "k"]);
await animationFrame();
await contains(".o_command_palette_search input").edit("/", { confirm: false });
await animationFrame();
expect(".o_command_palette").toHaveCount(1);
await press("enter");
await animationFrame();
// empty screen for now, wait for actual action to show up
await animationFrame();
expect(".o_menu_brand").toHaveText("Contact");
expect(".test_client_action").toHaveText("ClientAction_Id 1");
});
test.tags("desktop");
test("opens a menu items", async () => {
await mountWithCleanup(WebClient);
expect(".o_menu_brand").toHaveCount(0);
await press(["control", "k"]);
await animationFrame();
await contains(".o_command_palette_search input").edit("/sal", { confirm: false });
await animationFrame();
expect(".o_command_palette").toHaveCount(1);
expect(".o_command_category").toHaveCount(2);
await contains("#o_command_2").click();
await animationFrame();
// empty screen for now, wait for actual action to show up
await animationFrame();
expect(".o_menu_brand").toHaveText("Sales");
expect(".test_client_action").toHaveText("ClientAction_Report");
});
test.tags("desktop");
test("open a menu item when a dialog is displayed", async () => {
class CustomDialog extends Component {
static template = xml`<Dialog contentClass="'test'">content</Dialog>`;
static components = { Dialog };
static props = ["*"];
}
await mountWithCleanup(WebClient);
expect(".o_menu_brand").toHaveCount(0);
expect(".modal .test").toHaveCount(0);
getService("dialog").add(CustomDialog);
await animationFrame();
expect(".modal .test").toHaveCount(1);
await press(["control", "k"]);
await animationFrame();
await contains(".o_command_palette_search input").edit("/sal", { confirm: false });
await animationFrame();
expect(".o_command_palette").toHaveCount(1);
expect(".modal .test").toHaveCount(1);
await contains("#o_command_2").click();
await animationFrame();
expect(".o_menu_brand").toHaveText("Sales");
});

View file

@ -0,0 +1,196 @@
import { test, expect } from "@odoo/hoot";
import { click, edit } from "@odoo/hoot-dom";
import { animationFrame, tick } from "@odoo/hoot-mock";
import { Component, reactive, useState, xml } from "@odoo/owl";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { useDateTimePicker } from "@web/core/datetime/datetime_hook";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
const { DateTime } = luxon;
/**
* @param {() => any} setup
*/
const mountInput = async (setup) => {
await mountWithCleanup(Root, { props: { setup } });
};
class Root extends Component {
static components = { DateTimeInput };
static template = xml`<input type="text" class="datetime_hook_input" t-ref="start-date"/>`;
static props = ["*"];
setup() {
this.props.setup();
}
}
test("reactivity: update inert object", async () => {
const pickerProps = {
value: false,
type: "date",
};
await mountInput(() => {
useDateTimePicker({ pickerProps });
});
expect(".datetime_hook_input").toHaveValue("");
pickerProps.value = DateTime.fromSQL("2023-06-06");
await tick();
expect(".datetime_hook_input").toHaveText("");
});
test("reactivity: useState & update getter object", async () => {
const pickerProps = reactive({
value: false,
type: "date",
});
await mountInput(() => {
const state = useState(pickerProps);
state.value; // artificially subscribe to value
useDateTimePicker({
get pickerProps() {
return pickerProps;
},
});
});
expect(".datetime_hook_input").toHaveValue("");
pickerProps.value = DateTime.fromSQL("2023-06-06");
await animationFrame();
expect(".datetime_hook_input").toHaveValue("06/06/2023");
});
test("reactivity: update reactive object returned by the hook", async () => {
let pickerProps;
const defaultPickerProps = {
value: false,
type: "date",
};
await mountInput(() => {
pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }).state;
});
expect(".datetime_hook_input").toHaveValue("");
expect(pickerProps.value).toBe(false);
pickerProps.value = DateTime.fromSQL("2023-06-06");
await tick();
expect(".datetime_hook_input").toHaveValue("06/06/2023");
});
test("returned value is updated when input has changed", async () => {
let pickerProps;
const defaultPickerProps = {
value: false,
type: "date",
};
await mountInput(() => {
pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }).state;
});
expect(".datetime_hook_input").toHaveValue("");
expect(pickerProps.value).toBe(false);
await click(".datetime_hook_input");
await edit("06/06/2023");
await click(document.body);
expect(pickerProps.value.toSQL().split(" ")[0]).toBe("2023-06-06");
});
test("value is not updated if it did not change", async () => {
const getShortDate = (date) => date.toSQL().split(" ")[0];
let pickerProps;
const defaultPickerProps = {
value: DateTime.fromSQL("2023-06-06"),
type: "date",
};
await mountInput(() => {
pickerProps = useDateTimePicker({
pickerProps: defaultPickerProps,
onApply: (value) => {
expect.step(getShortDate(value));
},
}).state;
});
expect(".datetime_hook_input").toHaveValue("06/06/2023");
expect(getShortDate(pickerProps.value)).toBe("2023-06-06");
await click(".datetime_hook_input");
await edit("06/06/2023");
await click(document.body);
expect(getShortDate(pickerProps.value)).toBe("2023-06-06");
expect.verifySteps([]);
await click(".datetime_hook_input");
await edit("07/07/2023");
await click(document.body);
expect(getShortDate(pickerProps.value)).toBe("2023-07-07");
expect.verifySteps(["2023-07-07"]);
});
test("close popover when owner component is unmounted", async() => {
class Child extends Component {
static components = { DateTimeInput };
static props = [];
static template = xml`
<div>
<input type="text" class="datetime_hook_input" t-ref="start-date"/>
</div>
`;
setup() {
useDateTimePicker({
pickerProps: {
value: [false, false],
type: "date",
range: true,
}
});
}
}
const { resolve: hidePopover, promise } = Promise.withResolvers();
class DateTimeToggler extends Component {
static components = { Child };
static props = [];
static template = xml`<Child t-if="!state.hidden"/>`;
setup() {
this.state = useState({
hidden: false,
});
promise.then(() => {
this.state.hidden = true;
});
}
}
await mountWithCleanup(DateTimeToggler);
await click("input.datetime_hook_input");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
// we can't simply add a button because `useClickAway` will be triggered, thus closing the popover properly
hidePopover();
await animationFrame();
await animationFrame();
expect(".o_datetime_picker").toHaveCount(0);
});

View file

@ -0,0 +1,577 @@
import { test, expect, describe } from "@odoo/hoot";
import { Component, xml } from "@odoo/owl";
import { assertDateTimePicker, getPickerCell } from "../../datetime/datetime_test_helpers";
import { animationFrame } from "@odoo/hoot-mock";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
import {
contains,
defineParams,
makeMockEnv,
mountWithCleanup,
serverState,
} from "@web/../tests/web_test_helpers";
import { click, edit, queryAll, queryFirst, select } from "@odoo/hoot-dom";
const { DateTime } = luxon;
class DateTimeInputComp extends Component {
static components = { DateTimeInput };
static template = xml`<DateTimeInput t-props="props" />`;
static props = ["*"];
}
async function changeLang(lang) {
serverState.lang = lang;
await makeMockEnv();
}
describe("DateTimeInput (date)", () => {
defineParams({
lang_parameters: {
date_format: "%d/%m/%Y",
time_format: "%H:%M:%S",
},
});
test("basic rendering", async () => {
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
},
});
expect(".o_datetime_input").toHaveCount(1);
assertDateTimePicker(false);
expect(".o_datetime_input").toHaveValue("09/01/1997");
await click(".o_datetime_input");
await animationFrame();
assertDateTimePicker({
title: "January 1997",
date: [
{
cells: [
[0, 0, 0, 1, 2, 3, 4],
[5, 6, 7, 8, [9], 10, 11],
[12, 13, 14, 15, 16, 17, 18],
[19, 20, 21, 22, 23, 24, 25],
[26, 27, 28, 29, 30, 31, 0],
],
daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
weekNumbers: [1, 2, 3, 4, 5],
},
],
});
});
test("pick a date", async () => {
expect.assertions(4);
await makeMockEnv();
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
onChange: (date) => {
expect.step("datetime-changed");
expect(date.toFormat("dd/MM/yyyy")).toBe("08/02/1997", {
message: "Event should transmit the correct date",
});
},
},
});
await contains(".o_datetime_input").click();
await contains(".o_datetime_picker .o_next").click();
expect.verifySteps([]);
await contains(getPickerCell("8")).click();
expect(".o_datetime_input").toHaveValue("08/02/1997");
expect.verifySteps(["datetime-changed"]);
});
test("pick a date with FR locale", async () => {
expect.assertions(4);
await changeLang("fr-FR");
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
format: "dd MMM, yyyy",
onChange: (date) => {
expect.step("datetime-changed");
expect(date.toFormat("dd/MM/yyyy")).toBe("19/09/1997", {
message: "Event should transmit the correct date",
});
},
},
});
expect(".o_datetime_input").toHaveValue("09 janv., 1997");
await contains(".o_datetime_input").click();
await contains(".o_zoom_out").click();
await contains(getPickerCell("sept.")).click();
await contains(getPickerCell("19")).click();
await animationFrame();
expect(".o_datetime_input").toHaveValue("19 sept., 1997");
expect.verifySteps(["datetime-changed"]);
});
test("pick a date with locale (locale with different symbols)", async () => {
expect.assertions(5);
await changeLang("gu");
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
format: "dd MMM, yyyy",
onChange: (date) => {
expect.step("datetime-changed");
expect(date.toFormat("dd/MM/yyyy")).toBe("19/09/1997", {
message: "Event should transmit the correct date",
});
},
},
});
expect(".o_datetime_input").toHaveValue("09 જાન્યુ, 1997");
await contains(".o_datetime_input").click();
expect(".o_datetime_input").toHaveValue("09 જાન્યુ, 1997");
await contains(".o_zoom_out").click();
await contains(getPickerCell("સપ્ટે")).click();
await contains(getPickerCell("19")).click();
await animationFrame();
expect(".o_datetime_input").toHaveValue("19 સપ્ટે, 1997");
expect.verifySteps(["datetime-changed"]);
});
test("enter a date value", async () => {
expect.assertions(4);
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
onChange: (date) => {
expect.step("datetime-changed");
expect(date.toFormat("dd/MM/yyyy")).toBe("08/02/1997", {
message: "Event should transmit the correct date",
});
},
},
});
expect.verifySteps([]);
await contains(".o_datetime_input").click();
await edit("08/02/1997");
await animationFrame();
await click(document.body);
await animationFrame();
expect.verifySteps(["datetime-changed"]);
await click(".o_datetime_input");
await animationFrame();
expect(getPickerCell("8")).toHaveClass("o_selected");
});
test("Date format is correctly set", async () => {
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
format: "yyyy/MM/dd",
},
});
expect(".o_datetime_input").toHaveValue("1997/01/09");
// Forces an update to assert that the registered format is the correct one
await contains(".o_datetime_input").click();
expect(".o_datetime_input").toHaveValue("1997/01/09");
});
test.tags("mobile");
test("popover should have enough space to be displayed", async () => {
class Root extends Component {
static components = { DateTimeInput };
static template = xml`<div class="d-flex"><DateTimeInput t-props="props" /></div>`;
static props = ["*"];
}
await mountWithCleanup(Root, {
props: {
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
},
});
const parent = queryFirst(".o_datetime_input").parentElement;
const initialParentHeight = parent.clientHeight;
await contains(".o_datetime_input", { root: parent }).click();
const pickerRectHeight = queryFirst(".o_datetime_picker").clientHeight;
expect(initialParentHeight).toBeLessThan(pickerRectHeight, {
message: "initial height shouldn't be big enough to display the picker",
});
expect(parent.clientHeight).toBeGreaterThan(pickerRectHeight, {
message: "initial height should be big enough to display the picker",
});
});
});
describe("DateTimeInput (datetime)", () => {
defineParams({
lang_parameters: {
date_format: "%d/%m/%Y",
time_format: "%H:%M:%S",
},
});
test("basic rendering", async () => {
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
},
});
expect(".o_datetime_input").toHaveCount(1);
assertDateTimePicker(false);
expect(".o_datetime_input").toHaveValue("09/01/1997 12:30:01");
await contains(".o_datetime_input").click();
assertDateTimePicker({
title: "January 1997",
date: [
{
cells: [
[0, 0, 0, 1, 2, 3, 4],
[5, 6, 7, 8, [9], 10, 11],
[12, 13, 14, 15, 16, 17, 18],
[19, 20, 21, 22, 23, 24, 25],
[26, 27, 28, 29, 30, 31, 0],
],
daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
weekNumbers: [1, 2, 3, 4, 5],
},
],
time: [[12, 30]],
});
});
test("pick a date and time", async () => {
await makeMockEnv();
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
onChange: (date) => expect.step(date.toSQL().split(".")[0]),
},
});
expect(".o_datetime_input").toHaveValue("09/01/1997 12:30:01");
await contains(".o_datetime_input").click();
// Select February 8th
await contains(".o_datetime_picker .o_next").click();
await contains(getPickerCell("8")).click();
// Select 15:45
const [hourSelect, minuteSelect] = queryAll(".o_time_picker_select");
await select("15", { target: hourSelect });
await animationFrame();
await select("45", { target: minuteSelect });
await animationFrame();
expect(".o_datetime_input").toHaveValue("08/02/1997 15:45:01");
expect.verifySteps(["1997-02-08 12:30:01", "1997-02-08 15:30:01", "1997-02-08 15:45:01"]);
});
test("pick a date and time with locale", async () => {
await changeLang("fr_FR");
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
format: "dd MMM, yyyy HH:mm:ss",
onChange: (date) => expect.step(date.toSQL().split(".")[0]),
},
});
expect(".o_datetime_input").toHaveValue("09 janv., 1997 12:30:01");
await contains(".o_datetime_input").click();
await contains(".o_zoom_out").click();
await contains(getPickerCell("sept.")).click();
await contains(getPickerCell("1")).click();
// Select 15:45
const [hourSelect, minuteSelect] = queryAll(".o_time_picker_select");
await select("15", { target: hourSelect });
await animationFrame();
await select("45", { target: minuteSelect });
await animationFrame();
expect(".o_datetime_input").toHaveValue("01 sept., 1997 15:45:01");
expect.verifySteps(["1997-09-01 12:30:01", "1997-09-01 15:30:01", "1997-09-01 15:45:01"]);
});
test("pick a time with 12 hour format without meridiem", async () => {
defineParams({
lang_parameters: {
date_format: "%d/%m/%Y",
time_format: "%I:%M:%S",
},
});
await makeMockEnv();
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997 08:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
onChange: (date) => expect.step(date.toSQL().split(".")[0]),
},
});
expect(".o_datetime_input").toHaveValue("09/01/1997 08:30:01");
await contains(".o_datetime_input").click();
const [, minuteSelect] = queryAll(".o_time_picker_select");
await select("15", { target: minuteSelect });
await click(document.body);
await animationFrame();
expect.verifySteps(["1997-01-09 08:15:01"]);
});
test("enter a datetime value", async () => {
expect.assertions(7);
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
onChange: (date) => {
expect.step("datetime-changed");
expect(date.toFormat("dd/MM/yyyy HH:mm:ss")).toBe("08/02/1997 15:45:05", {
message: "Event should transmit the correct date",
});
},
},
});
expect.verifySteps([]);
await contains(".o_datetime_input").click();
await edit("08/02/1997 15:45:05");
await animationFrame();
await click(document.body);
await animationFrame();
expect.verifySteps(["datetime-changed"]);
await contains(".o_datetime_input").click();
expect(".o_datetime_input").toHaveValue("08/02/1997 15:45:05");
expect(getPickerCell("8")).toHaveClass("o_selected");
const [hourSelect, minuteSelect] = queryAll(".o_time_picker_select");
expect(hourSelect).toHaveValue("15");
expect(minuteSelect).toHaveValue("45");
});
test("Date time format is correctly set", async () => {
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
format: "HH:mm:ss yyyy/MM/dd",
},
});
expect(".o_datetime_input").toHaveValue("12:30:01 1997/01/09");
// Forces an update to assert that the registered format is the correct one
await contains(".o_datetime_input").click();
expect(".o_datetime_input").toHaveValue("12:30:01 1997/01/09");
});
test("Datepicker works with norwegian locale", async () => {
expect.assertions(7);
await changeLang("nb");
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/04/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
format: "dd MMM, yyyy",
onChange(date) {
expect.step("datetime-changed");
expect(date.toFormat("dd/MM/yyyy")).toBe("01/04/1997", {
message: "Event should transmit the correct date",
});
},
},
});
expect(".o_datetime_input").toHaveValue("09 apr., 1997");
// Forces an update to assert that the registered format is the correct one
await contains(".o_datetime_input").click();
expect(".o_datetime_input").toHaveValue("09 apr., 1997");
await contains(getPickerCell("1")).click();
expect(".o_datetime_input").toHaveValue("01 apr., 1997");
expect.verifySteps(["datetime-changed"]);
await click(".o_apply");
await animationFrame();
expect.verifySteps(["datetime-changed"]);
});
test("Datepicker works with dots and commas in format", async () => {
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("10/03/2023 13:14:27", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
format: "dd.MM,yyyy",
},
});
expect(".o_datetime_input").toHaveValue("10.03,2023");
// Forces an update to assert that the registered format is the correct one
await contains(".o_datetime_input").click();
expect(".o_datetime_input").toHaveValue("10.03,2023");
});
test("start with no value", async () => {
expect.assertions(5);
await makeMockEnv();
await mountWithCleanup(DateTimeInputComp, {
props: {
type: "datetime",
onChange(date) {
expect.step("datetime-changed");
expect(date.toFormat("dd/MM/yyyy HH:mm:ss")).toBe("08/02/1997 15:45:05", {
message: "Event should transmit the correct date",
});
},
},
});
expect(".o_datetime_input").toHaveValue("");
expect.verifySteps([]);
await contains(".o_datetime_input").click();
await edit("08/02/1997 15:45:05");
await animationFrame();
await click(document.body);
await animationFrame();
expect.verifySteps(["datetime-changed"]);
expect(".o_datetime_input").toHaveValue("08/02/1997 15:45:05");
});
test("Clicking close button closes datetime picker", async () => {
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
format: "dd MMM, yyyy HH:mm:ss",
},
});
await contains(".o_datetime_input").click();
await contains(".o_datetime_picker .o_datetime_buttons .btn-secondary").click();
expect(".o_datetime_picker").toHaveCount(0);
});
test("check datepicker in localization with textual month format", async () => {
defineParams({
lang_parameters: {
date_format: "%b/%d/%Y",
time_format: "%H:%M:%S",
},
});
let onChangeDate;
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
onChange: (date) => (onChangeDate = date),
},
});
expect(".o_datetime_input").toHaveValue("Jan/09/1997");
await contains(".o_datetime_input").click();
await contains(getPickerCell("5")).click();
expect(".o_datetime_input").toHaveValue("Jan/05/1997");
expect(onChangeDate.toFormat("dd/MM/yyyy")).toBe("05/01/1997");
});
test("arab locale, latin numbering system as input", async () => {
defineParams({
lang_parameters: {
date_format: "%d %b, %Y",
time_format: "%H:%M:%S",
},
});
await changeLang("ar-001");
await mountWithCleanup(DateTimeInputComp);
await contains(".o_datetime_input").click();
await edit("٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
await animationFrame();
await click(document.body);
await animationFrame();
expect(".o_datetime_input").toHaveValue("٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
await contains(".o_datetime_input").click();
await edit("15 07, 2020 12:30:43");
await animationFrame();
await click(document.body);
await animationFrame();
expect(".o_datetime_input").toHaveValue("١٥ يوليو, ٢٠٢٠ ١٢:٣٠:٤٣");
});
});

View file

@ -0,0 +1,460 @@
import { test, expect } from "@odoo/hoot";
import { Deferred, animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { click, press } from "@odoo/hoot-dom";
import { Pager } from "@web/core/pager/pager";
import { Component, useState, xml } from "@odoo/owl";
import { contains, mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { config as transitionConfig } from "@web/core/transition";
class PagerController extends Component {
static template = xml`<Pager t-props="state" />`;
static components = { Pager };
static props = ["*"];
setup() {
this.state = useState({ ...this.props });
}
async updateProps(nextProps) {
Object.assign(this.state, nextProps);
await animationFrame();
}
}
test("basic interactions", async () => {
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 4,
total: 10,
async onUpdate(data) {
expect.step(`offset: ${data.offset}, limit: ${data.limit}`);
await pager.updateProps(data);
},
},
});
await contains(".o_pager button.o_pager_next:enabled").click();
await contains(".o_pager button.o_pager_previous:enabled").click();
expect.verifySteps(["offset: 4, limit: 4", "offset: 0, limit: 4"]);
});
test.tags("desktop");
test("basic interactions on desktop", async () => {
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 4,
total: 10,
async onUpdate(data) {
await pager.updateProps(data);
},
},
});
expect(".o_pager_counter .o_pager_value").toHaveText("1-4");
await click(".o_pager button.o_pager_next");
await animationFrame();
expect(".o_pager_counter .o_pager_value").toHaveText("5-8");
});
test.tags("mobile");
test("basic interactions on mobile", async () => {
patchWithCleanup(transitionConfig, { disabled: true });
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 4,
total: 10,
async onUpdate(data) {
await pager.updateProps(data);
},
},
});
expect(".o_pager_indicator").toHaveCount(0);
await click(".o_pager button.o_pager_next");
await animationFrame();
await animationFrame(); // transition
expect(".o_pager_indicator").toHaveCount(1);
expect(".o_pager_indicator .o_pager_value").toHaveText("5-8");
await runAllTimers();
await animationFrame();
expect(".o_pager_indicator").toHaveCount(0);
await click(".o_pager button.o_pager_previous");
await animationFrame();
await animationFrame(); // transition
expect(".o_pager_indicator").toHaveCount(1);
expect(".o_pager_indicator .o_pager_value").toHaveText("1-4");
await runAllTimers();
await animationFrame();
expect(".o_pager_indicator").toHaveCount(0);
});
test.tags("desktop");
test("edit the pager", async () => {
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 4,
total: 10,
async onUpdate(data) {
await pager.updateProps(data);
},
},
});
await click(".o_pager_value");
await animationFrame();
expect("input").toHaveCount(1);
expect(".o_pager_counter .o_pager_value").toHaveValue("1-4");
await contains("input.o_pager_value").edit("1-6");
await click(document.body);
await animationFrame();
await animationFrame();
expect("input").toHaveCount(0);
expect(".o_pager_counter .o_pager_value").toHaveText("1-6");
});
test.tags("desktop");
test("keydown on pager with same value", async () => {
await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 4,
total: 10,
onUpdate(data) {
expect.step("pager-changed");
},
},
});
await click(".o_pager_value");
await animationFrame();
expect("input").toHaveCount(1);
expect(".o_pager_counter .o_pager_value").toHaveValue("1-4");
expect.verifySteps([]);
await press("Enter");
await animationFrame();
expect("input").toHaveCount(0);
expect(".o_pager_counter .o_pager_value").toHaveText("1-4");
expect.verifySteps(["pager-changed"]);
});
test.tags("desktop");
test("pager value formatting", async () => {
expect.assertions(8);
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 4,
total: 10,
async onUpdate(data) {
await pager.updateProps(data);
},
},
});
expect(".o_pager_counter .o_pager_value").toHaveText("1-4");
async function inputAndAssert(inputValue, expected) {
await click(".o_pager_counter .o_pager_value");
await animationFrame();
await contains("input.o_pager_value").edit(inputValue);
await click(document.body);
await animationFrame();
await animationFrame();
expect(".o_pager_counter .o_pager_value").toHaveText(expected);
}
await inputAndAssert("4-4", "4");
await inputAndAssert("1-11", "1-10");
await inputAndAssert("20-15", "10");
await inputAndAssert("6-5", "10");
await inputAndAssert("definitelyValidNumber", "10");
await inputAndAssert(" 1 , 2 ", "1-2");
await inputAndAssert("3 8", "3-8");
});
test("pager disabling", async () => {
const reloadPromise = new Deferred();
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 4,
total: 10,
// The goal here is to test the reactivity of the pager; in a
// typical views, we disable the pager after switching page
// to avoid switching twice with the same action (double click).
async onUpdate(data) {
// 1. Simulate a (long) server action
await reloadPromise;
// 2. Update the view with loaded data
await pager.updateProps(data);
},
},
});
// Click and check button is disabled
await click(".o_pager button.o_pager_next");
await animationFrame();
expect(".o_pager button.o_pager_next").toHaveAttribute("disabled");
await click(".o_pager button.o_pager_previous");
await animationFrame();
expect(".o_pager button.o_pager_previous").toHaveAttribute("disabled");
});
test.tags("desktop");
test("pager disabling on desktop", async () => {
const reloadPromise = new Deferred();
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 4,
total: 10,
// The goal here is to test the reactivity of the pager; in a
// typical views, we disable the pager after switching page
// to avoid switching twice with the same action (double click).
async onUpdate(data) {
// 1. Simulate a (long) server action
await reloadPromise;
// 2. Update the view with loaded data
await pager.updateProps(data);
},
},
});
await click(".o_pager button.o_pager_next");
await animationFrame();
// Try to edit the pager value
await click(".o_pager_value");
await animationFrame();
expect("button").toHaveCount(2);
expect("button:nth-child(1)").toHaveAttribute("disabled");
expect("button:nth-child(2)").toHaveAttribute("disabled");
expect("span.o_pager_value").toHaveCount(1);
reloadPromise.resolve();
await animationFrame();
await animationFrame();
expect("button").toHaveCount(2);
expect("button:nth-child(1)").not.toHaveAttribute("disabled");
expect("button:nth-child(2)").not.toHaveAttribute("disabled");
expect(".o_pager_counter .o_pager_value").toHaveText("5-8");
await click(".o_pager_value");
await animationFrame();
expect("input.o_pager_value").toHaveCount(1);
});
test.tags("desktop");
test("desktop input interaction", async () => {
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 4,
total: 10,
async onUpdate(data) {
await pager.updateProps(data);
},
},
});
await click(".o_pager_value");
await animationFrame();
expect("input").toHaveCount(1);
expect("input").toBeFocused();
await click(document.body);
await animationFrame();
await animationFrame();
expect("input").toHaveCount(0);
});
test.tags("desktop");
test("updateTotal props: click on total", async () => {
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 5,
total: 10,
onUpdate() {},
async updateTotal() {
await pager.updateProps({ total: 25, updateTotal: undefined });
},
},
});
expect(".o_pager_value").toHaveText("1-5");
expect(".o_pager_limit").toHaveText("10+");
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
await click(".o_pager_limit_fetch");
await animationFrame();
expect(".o_pager_value").toHaveText("1-5");
expect(".o_pager_limit").toHaveText("25");
expect(".o_pager_limit").not.toHaveClass("o_pager_limit_fetch");
});
test.tags("desktop");
test("updateTotal props: click next", async () => {
let tempTotal = 10;
const realTotal = 18;
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 5,
total: tempTotal,
async onUpdate(data) {
tempTotal = Math.min(realTotal, Math.max(tempTotal, data.offset + data.limit));
const nextProps = { ...data, total: tempTotal };
if (tempTotal === realTotal) {
nextProps.updateTotal = undefined;
}
await pager.updateProps(nextProps);
},
updateTotal() {},
},
});
expect(".o_pager_value").toHaveText("1-5");
expect(".o_pager_limit").toHaveText("10+");
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
await contains(".o_pager_next:enabled").click();
expect(".o_pager_value").toHaveText("6-10");
expect(".o_pager_limit").toHaveText("10+");
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
await contains(".o_pager_next:enabled").click();
expect(".o_pager_value").toHaveText("11-15");
expect(".o_pager_limit").toHaveText("15+");
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
await contains(".o_pager_next:enabled").click();
expect(".o_pager_value").toHaveText("16-18");
expect(".o_pager_limit").toHaveText("18");
expect(".o_pager_limit").not.toHaveClass("o_pager_limit_fetch");
});
test.tags("desktop");
test("updateTotal props: edit input", async () => {
let tempTotal = 10;
const realTotal = 18;
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 5,
total: tempTotal,
async onUpdate(data) {
tempTotal = Math.min(realTotal, Math.max(tempTotal, data.offset + data.limit));
const nextProps = { ...data, total: tempTotal };
if (tempTotal === realTotal) {
nextProps.updateTotal = undefined;
}
await pager.updateProps(nextProps);
},
updateTotal() {},
},
});
expect(".o_pager_value").toHaveText("1-5");
expect(".o_pager_limit").toHaveText("10+");
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
await click(".o_pager_value");
await animationFrame();
await contains("input.o_pager_value").edit("3-8");
await click(document.body);
await animationFrame();
await animationFrame();
expect(".o_pager_value").toHaveText("3-8");
expect(".o_pager_limit").toHaveText("10+");
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
await click(".o_pager_value");
await animationFrame();
await contains("input.o_pager_value").edit("3-20");
await click(document.body);
await animationFrame();
await animationFrame();
expect(".o_pager_value").toHaveText("3-18");
expect(".o_pager_limit").toHaveText("18");
expect(".o_pager_limit").not.toHaveClass("o_pager_limit_fetch");
});
test.tags("desktop");
test("updateTotal props: can use next even if single page", async () => {
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 5,
total: 5,
async onUpdate(data) {
await pager.updateProps({ ...data, total: 10 });
},
updateTotal() {},
},
});
expect(".o_pager_value").toHaveText("1-5");
expect(".o_pager_limit").toHaveText("5+");
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
await click(".o_pager_next");
await animationFrame();
expect(".o_pager_value").toHaveText("6-10");
expect(".o_pager_limit").toHaveText("10+");
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
});
test.tags("desktop");
test("updateTotal props: click previous", async () => {
const pager = await mountWithCleanup(PagerController, {
props: {
offset: 0,
limit: 5,
total: 10,
async onUpdate(data) {
await pager.updateProps(data);
},
async updateTotal() {
const total = 23;
await pager.updateProps({ total, updateTotal: undefined });
return total;
},
},
});
expect(".o_pager_value").toHaveText("1-5");
expect(".o_pager_limit").toHaveText("10+");
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
await click(".o_pager_previous");
await animationFrame();
await animationFrame(); // double call to updateProps
expect(".o_pager_value").toHaveText("21-23");
expect(".o_pager_limit").toHaveText("23");
expect(".o_pager_limit").not.toHaveClass("o_pager_limit_fetch");
});

View file

@ -0,0 +1,25 @@
import { PagerIndicator } from "@web/core/pager/pager_indicator";
import { mountWithCleanup, patchWithCleanup } from "../../web_test_helpers";
import { config as transitionConfig } from "@web/core/transition";
import { expect, test } from "@odoo/hoot";
import { PAGER_UPDATED_EVENT, pagerBus } from "@web/core/pager/pager";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
test("displays the pager indicator", async () => {
patchWithCleanup(transitionConfig, { disabled: true });
await mountWithCleanup(PagerIndicator, { noMainContainer: true });
expect(".o_pager_indicator").toHaveCount(0, {
message: "the pager indicator should not be displayed",
});
pagerBus.trigger(PAGER_UPDATED_EVENT, { value: "1-4", total: 10 });
await animationFrame();
expect(".o_pager_indicator").toHaveCount(1, {
message: "the pager indicator should be displayed",
});
expect(".o_pager_indicator").toHaveText("1-4 / 10");
await runAllTimers();
await animationFrame();
expect(".o_pager_indicator").toHaveCount(0, {
message: "the pager indicator should not be displayed",
});
});

View file

@ -0,0 +1,296 @@
import { expect, test, describe, destroy } from "@odoo/hoot";
import { tick, Deferred } from "@odoo/hoot-mock";
import { press } from "@odoo/hoot-dom";
import { mountWithCleanup, contains, makeDialogMockEnv } from "@web/../tests/web_test_helpers";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { Component, xml } from "@odoo/owl";
describe.current.tags("desktop");
test("check content confirmation dialog", async () => {
const env = await makeDialogMockEnv();
await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {},
confirm: () => {},
cancel: () => {},
},
});
expect(".modal-header").toHaveText("Confirmation");
expect(".modal-body").toHaveText("Some content");
});
test("Without dismiss callback: pressing escape to close the dialog", async () => {
const env = await makeDialogMockEnv();
await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
expect.step("Close action");
},
confirm: () => {
throw new Error("should not be called");
},
cancel: () => {
expect.step("Cancel action");
},
},
});
expect.verifySteps([]);
await press("escape");
await tick();
expect.verifySteps(["Cancel action", "Close action"]);
});
test("With dismiss callback: pressing escape to close the dialog", async () => {
const env = await makeDialogMockEnv();
await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
expect.step("Close action");
},
confirm: () => {
throw new Error("should not be called");
},
cancel: () => {
throw new Error("should not be called");
},
dismiss: () => {
expect.step("Dismiss action");
},
},
});
await press("escape");
await tick();
expect.verifySteps(["Dismiss action", "Close action"]);
});
test("Without dismiss callback: clicking on 'X' to close the dialog", async () => {
const env = await makeDialogMockEnv();
await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
expect.step("Close action");
},
confirm: () => {
throw new Error("should not be called");
},
cancel: () => {
expect.step("Cancel action");
},
},
});
await contains(".modal-header .btn-close").click();
expect.verifySteps(["Cancel action", "Close action"]);
});
test("With dismiss callback: clicking on 'X' to close the dialog", async () => {
const env = await makeDialogMockEnv();
await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
expect.step("Close action");
},
confirm: () => {
throw new Error("should not be called");
},
cancel: () => {
throw new Error("should not be called");
},
dismiss: () => {
expect.step("Dismiss action");
},
},
});
await contains(".modal-header .btn-close").click();
expect.verifySteps(["Dismiss action", "Close action"]);
});
test("clicking on 'Ok'", async () => {
const env = await makeDialogMockEnv();
await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
expect.step("Close action");
},
confirm: () => {
expect.step("Confirm action");
},
cancel: () => {
throw new Error("should not be called");
},
},
});
expect.verifySteps([]);
await contains(".modal-footer .btn-primary").click();
expect.verifySteps(["Confirm action", "Close action"]);
});
test("clicking on 'Cancel'", async () => {
const env = await makeDialogMockEnv();
await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
expect.step("Close action");
},
confirm: () => {
throw new Error("should not be called");
},
cancel: () => {
expect.step("Cancel action");
},
},
});
expect.verifySteps([]);
await contains(".modal-footer .btn-secondary").click();
expect.verifySteps(["Cancel action", "Close action"]);
});
test("can't click twice on 'Ok'", async () => {
const env = await makeDialogMockEnv();
await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {},
confirm: () => {
expect.step("Confirm action");
},
cancel: () => {},
},
});
expect.verifySteps([]);
expect(".modal-footer .btn-primary").not.toHaveAttribute("disabled");
expect(".modal-footer .btn-secondary").not.toHaveAttribute("disabled");
await contains(".modal-footer .btn-primary").click();
expect(".modal-footer .btn-primary").toHaveAttribute("disabled");
expect(".modal-footer .btn-secondary").toHaveAttribute("disabled");
expect.verifySteps(["Confirm action"]);
});
test("can't click twice on 'Cancel'", async () => {
const env = await makeDialogMockEnv();
await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {},
confirm: () => {},
cancel: () => {
expect.step("Cancel action");
},
},
});
expect.verifySteps([]);
expect(".modal-footer .btn-primary").not.toHaveAttribute("disabled");
expect(".modal-footer .btn-secondary").not.toHaveAttribute("disabled");
await contains(".modal-footer .btn-secondary").click();
expect(".modal-footer .btn-primary").toHaveAttribute("disabled");
expect(".modal-footer .btn-secondary").toHaveAttribute("disabled");
expect.verifySteps(["Cancel action"]);
});
test("can't cancel (with escape) after confirm", async () => {
const def = new Deferred();
const env = await makeDialogMockEnv();
await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
expect.step("Close action");
},
confirm: () => {
expect.step("Confirm action");
return def;
},
cancel: () => {
throw new Error("should not cancel");
},
},
});
await contains(".modal-footer .btn-primary").click();
expect.verifySteps(["Confirm action"]);
await press("escape");
await tick();
expect.verifySteps([]);
def.resolve();
await tick();
expect.verifySteps(["Close action"]);
});
test("wait for confirm callback before closing", async () => {
const def = new Deferred();
const env = await makeDialogMockEnv();
await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
expect.step("Close action");
},
confirm: () => {
expect.step("Confirm action");
return def;
},
},
});
await contains(".modal-footer .btn-primary").click();
expect.verifySteps(["Confirm action"]);
def.resolve();
await tick();
expect.verifySteps(["Close action"]);
});
test("Focus is correctly restored after confirmation", async () => {
const env = await makeDialogMockEnv();
class Parent extends Component {
static template = xml`<div class="my-comp"><input type="text" class="my-input"/></div>`;
static props = ["*"];
}
await mountWithCleanup(Parent, { env });
await contains(".my-input").focus();
expect(".my-input").toBeFocused();
const dialog = await mountWithCleanup(ConfirmationDialog, {
env,
props: {
body: "Some content",
title: "Confirmation",
confirm: () => {},
close: () => {},
},
});
expect(".modal-footer .btn-primary").toBeFocused();
await contains(".modal-footer .btn-primary").click();
expect(document.body).toBeFocused();
destroy(dialog);
await Promise.resolve();
expect(".my-input").toBeFocused();
});

View file

@ -0,0 +1,78 @@
import { evalPartialContext, makeContext } from "@web/core/context";
import { expect, test, describe } from "@odoo/hoot";
describe.current.tags("headless");
describe("makeContext", () => {
test("return empty context", () => {
expect(makeContext([])).toEqual({});
});
test("duplicate a context", () => {
const ctx1 = { a: 1 };
const ctx2 = makeContext([ctx1]);
expect(ctx1).not.toBe(ctx2);
expect(ctx1).toEqual(ctx2);
});
test("can accept undefined or empty string", () => {
expect(makeContext([undefined])).toEqual({});
expect(makeContext([{ a: 1 }, undefined, { b: 2 }])).toEqual({ a: 1, b: 2 });
expect(makeContext([""])).toEqual({});
expect(makeContext([{ a: 1 }, "", { b: 2 }])).toEqual({ a: 1, b: 2 });
});
test("evaluate strings", () => {
expect(makeContext(["{'a': 33}"])).toEqual({ a: 33 });
});
test("evaluated context is used as evaluation context along the way", () => {
expect(makeContext([{ a: 1 }, "{'a': a + 1}"])).toEqual({ a: 2 });
expect(makeContext([{ a: 1 }, "{'b': a + 1}"])).toEqual({ a: 1, b: 2 });
expect(makeContext([{ a: 1 }, "{'b': a + 1}", "{'c': b + 1}"])).toEqual({
a: 1,
b: 2,
c: 3,
});
expect(makeContext([{ a: 1 }, "{'b': a + 1}", "{'a': b + 1}"])).toEqual({ a: 3, b: 2 });
});
test("initial evaluation context", () => {
expect(makeContext(["{'a': a + 1}"], { a: 1 })).toEqual({ a: 2 });
expect(makeContext(["{'b': a + 1}"], { a: 1 })).toEqual({ b: 2 });
});
});
describe("evalPartialContext", () => {
test("static contexts", () => {
expect(evalPartialContext("{}", {})).toEqual({});
expect(evalPartialContext("{'a': 1}", {})).toEqual({ a: 1 });
expect(evalPartialContext("{'a': 'b'}", {})).toEqual({ a: "b" });
expect(evalPartialContext("{'a': true}", {})).toEqual({ a: true });
expect(evalPartialContext("{'a': None}", {})).toEqual({ a: null });
});
test("complete dynamic contexts", () => {
expect(evalPartialContext("{'a': a, 'b': 1}", { a: 2 })).toEqual({ a: 2, b: 1 });
});
test("partial dynamic contexts", () => {
expect(evalPartialContext("{'a': a}", {})).toEqual({});
expect(evalPartialContext("{'a': a, 'b': 1}", {})).toEqual({ b: 1 });
expect(evalPartialContext("{'a': a, 'b': b}", { a: 2 })).toEqual({ a: 2 });
});
test("value of type obj (15)", () => {
expect(evalPartialContext("{'a': a.b.c}", {})).toEqual({});
expect(evalPartialContext("{'a': a.b.c}", { a: {} })).toEqual({});
expect(evalPartialContext("{'a': a.b.c}", { a: { b: { c: 2 } } })).toEqual({ a: 2 });
});
test("value of type op (14)", () => {
expect(evalPartialContext("{'a': a + 1}", {})).toEqual({});
expect(evalPartialContext("{'a': a + b}", {})).toEqual({});
expect(evalPartialContext("{'a': a + b}", { a: 2 })).toEqual({});
expect(evalPartialContext("{'a': a + 1}", { a: 2 })).toEqual({ a: 3 });
expect(evalPartialContext("{'a': a + b}", { a: 2, b: 3 })).toEqual({ a: 5 });
});
});

View file

@ -0,0 +1,37 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { makeMockEnv, serverState } from "@web/../tests/web_test_helpers";
import { formatCurrency } from "@web/core/currency";
describe.current.tags("headless");
beforeEach(async () => {
await makeMockEnv(); // To start the localization service
});
test("formatCurrency", async () => {
serverState.currencies = [
{ id: 1, position: "after", symbol: "€" },
{ id: 2, position: "before", symbol: "$" },
];
expect(formatCurrency(200)).toBe("200.00");
expect(formatCurrency(1234567.654, 1)).toBe("1,234,567.65\u00a0€");
expect(formatCurrency(1234567.654, 2)).toBe("$\u00a01,234,567.65");
expect(formatCurrency(1234567.654, 44)).toBe("1,234,567.65");
expect(formatCurrency(1234567.654, 1, { noSymbol: true })).toBe("1,234,567.65");
expect(formatCurrency(8.0, 1, { humanReadable: true })).toBe("8.00\u00a0€");
expect(formatCurrency(1234567.654, 1, { humanReadable: true })).toBe("1.23M\u00a0€");
expect(formatCurrency(1990000.001, 1, { humanReadable: true })).toBe("1.99M\u00a0€");
expect(formatCurrency(1234567.654, 44, { digits: [69, 1] })).toBe("1,234,567.7");
expect(formatCurrency(1234567.654, 2, { digits: [69, 1] })).toBe("$\u00a01,234,567.7", {
message: "options digits should take over currency digits when both are defined",
});
});
test("formatCurrency without currency", async () => {
serverState.currencies = [];
expect(formatCurrency(1234567.654, 10, { humanReadable: true })).toBe("1.23M");
expect(formatCurrency(1234567.654, 10)).toBe("1,234,567.65");
});

View file

@ -0,0 +1,173 @@
import { expect } from "@odoo/hoot";
import {
click,
queryAll,
queryAllTexts,
queryAllValues,
queryFirst,
queryText,
} from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
const PICKER_COLS = 7;
/**
* @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) {
// Check for picker in DOM
if (expectedParams) {
expect(".o_datetime_picker").toHaveCount(1);
} else {
expect(".o_datetime_picker").toHaveCount(0);
return;
}
const { title, date, time } = expectedParams;
// Title
if (title) {
expect(".o_datetime_picker_header").toHaveCount(1);
expect(".o_datetime_picker_header").toHaveText(title);
} else {
expect(".o_datetime_picker_header").toHaveCount(0);
}
// Time picker
if (time) {
expect(".o_time_picker").toHaveCount(time.length);
for (let i = 0; i < time.length; i++) {
const expectedTime = time[i];
const values = queryAll(`.o_time_picker:nth-child(${i + 1}) .o_time_picker_select`).map(
(sel) => sel.value
);
const actual = [...values.slice(0, 2).map(Number), ...values.slice(2)];
expect(actual).toEqual(expectedTime, {
message: `time values should be [${expectedTime}]`,
});
}
} else {
expect(".o_time_picker").toHaveCount(0);
}
// Date picker
expect(".o_date_picker").toHaveCount(date.length);
let selectedCells = 0;
let invalidCells = 0;
let outOfRangeCells = 0;
let todayCells = 0;
for (let i = 0; i < date.length; i++) {
const { cells, daysOfWeek, weekNumbers } = date[i];
const cellEls = queryAll(`.o_date_picker:nth-child(${i + 1}) .o_date_item_cell`);
const pickerRows = cells.length;
expect(cellEls.length).toBe(pickerRows * PICKER_COLS, {
message: `picker should have ${
pickerRows * PICKER_COLS
} cells (${pickerRows} rows and ${PICKER_COLS} columns)`,
});
if (daysOfWeek) {
const actualDow = queryAllTexts(
`.o_date_picker:nth-child(${i + 1}) .o_day_of_week_cell`
);
expect(actualDow).toEqual(daysOfWeek, {
message: `picker should display the days of week: ${daysOfWeek
.map((dow) => `"${dow}"`)
.join(", ")}`,
});
}
if (weekNumbers) {
expect(
queryAllTexts(`.o_date_picker:nth-child(${i + 1}) .o_week_number_cell`).map(Number)
).toEqual(weekNumbers, {
message: `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];
let value = cell;
if (Array.isArray(cell)) {
// Selected
value = value[0];
selectedCells++;
expect(cellEl).toHaveClass("o_selected");
}
if (typeof value === "string") {
// Today
value = Number(value);
todayCells++;
expect(cellEl).toHaveClass("o_today");
}
if (value === 0) {
// Out of range
value = "";
outOfRangeCells++;
expect(cellEl).toHaveClass("o_out_of_range");
} else if (value < 0) {
// Invalid
value = Math.abs(value);
invalidCells++;
expect(cellEl).toHaveAttribute("disabled");
}
return String(value);
})
);
expect(cellEls.map((cell) => queryText(cell))).toEqual(expectedCells, {
message: `cell content should match the expected values: [${expectedCells.join(", ")}]`,
});
}
expect(".o_selected").toHaveCount(selectedCells);
expect(".o_datetime_button[disabled]").toHaveCount(invalidCells);
expect(".o_out_of_range").toHaveCount(outOfRangeCells);
expect(".o_today").toHaveCount(todayCells);
}
/**
* @param {RegExp | string} expr
* @param {boolean} [inBounds=false]
*/
export function getPickerCell(expr, inBounds = false) {
const cells = queryAll(
`.o_datetime_picker .o_date_item_cell${
inBounds ? ":not(.o_out_of_range)" : ""
}:contains("/^${expr}$/")`
);
return cells.length === 1 ? cells[0] : cells;
}
export function getPickerApplyButton() {
return queryFirst(".o_datetime_picker .o_datetime_buttons .o_apply");
}
export async function zoomOut() {
click(".o_zoom_out");
await animationFrame();
}
export function getTimePickers({ parse = false } = {}) {
return queryAll(".o_time_picker").map((timePickerEl) => {
if (parse) {
return queryAllValues(".o_time_picker_select", { root: timePickerEl });
}
return queryAll(".o_time_picker_select", { root: timePickerEl });
});
}

View file

@ -0,0 +1,804 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import {
click,
queryAll,
queryAllProperties,
queryAllTexts,
queryOne,
queryText,
} from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import {
clearRegistry,
contains,
defineModels,
defineWebModels,
fields,
getService,
makeDialogMockEnv,
models,
mountWithCleanup,
onRpc,
patchWithCleanup,
serverState,
webModels,
} from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import { useDebugCategory, useOwnDebugContext } from "@web/core/debug/debug_context";
import { DebugMenu } from "@web/core/debug/debug_menu";
import { becomeSuperuser, regenerateAssets } from "@web/core/debug/debug_menu_items";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
import { ActionDialog } from "@web/webclient/actions/action_dialog";
import { openViewItem } from "@web/webclient/debug/debug_items";
import { WebClient } from "@web/webclient/webclient";
class DebugMenuParent extends Component {
static template = xml`<DebugMenu/>`;
static components = { DebugMenu };
static props = ["*"];
setup() {
useOwnDebugContext({ categories: ["default", "custom"] });
}
}
const debugRegistry = registry.category("debug");
onRpc(async (args) => {
if (args.method === "has_access") {
return true;
}
if (args.route === "/web/dataset/call_kw/ir.attachment/regenerate_assets_bundles") {
expect.step("ir.attachment/regenerate_assets_bundles");
return true;
}
});
beforeEach(() => {
// Remove this service to clear the debug menu from anything else than what the test insert into
registry.category("services").remove("profiling");
clearRegistry(debugRegistry.category("default"));
clearRegistry(debugRegistry.category("custom"));
});
describe.tags("desktop");
describe("DebugMenu", () => {
test("can be rendered", async () => {
debugRegistry
.category("default")
.add("item_1", () => {
return {
type: "item",
description: "Item 1",
callback: () => {
expect.step("callback item_1");
},
sequence: 10,
section: "a",
};
})
.add("item_2", () => {
return {
type: "item",
description: "Item 2",
callback: () => {
expect.step("callback item_2");
},
sequence: 5,
section: "a",
};
})
.add("item_3", () => {
return {
type: "item",
description: "Item 3",
callback: () => {
expect.step("callback item_3");
},
section: "b",
};
})
.add("item_4", () => {
return null;
});
await mountWithCleanup(DebugMenuParent);
await contains("button.dropdown-toggle").click();
expect(".dropdown-menu .dropdown-item").toHaveCount(3);
expect(".dropdown-menu .dropdown-menu_group").toHaveCount(2);
const children = [...queryOne(".dropdown-menu").children] || [];
expect(children.map((el) => el.tagName)).toEqual(["DIV", "SPAN", "SPAN", "DIV", "SPAN"]);
expect(queryAllTexts(children)).toEqual(["a", "Item 2", "Item 1", "b", "Item 3"]);
for (const item of queryAll(".dropdown-menu .dropdown-item")) {
await click(item);
}
expect.verifySteps(["callback item_2", "callback item_1", "callback item_3"]);
});
test("items are sorted by sequence regardless of category", async () => {
debugRegistry
.category("default")
.add("item_1", () => {
return {
type: "item",
description: "Item 4",
sequence: 4,
};
})
.add("item_2", () => {
return {
type: "item",
description: "Item 1",
sequence: 1,
};
});
debugRegistry
.category("custom")
.add("item_1", () => {
return {
type: "item",
description: "Item 3",
sequence: 3,
};
})
.add("item_2", () => {
return {
type: "item",
description: "Item 2",
sequence: 2,
};
});
await mountWithCleanup(DebugMenuParent);
await contains("button.dropdown-toggle").click();
expect(queryAllTexts(".dropdown-menu .dropdown-item")).toEqual([
"Item 1",
"Item 2",
"Item 3",
"Item 4",
]);
});
test("Don't display the DebugMenu if debug mode is disabled", async () => {
const env = await makeDialogMockEnv();
await mountWithCleanup(ActionDialog, {
env,
props: { close: () => {} },
});
expect(".o_dialog").toHaveCount(1);
expect(".o_dialog .o_debug_manager .fa-bug").toHaveCount(0);
});
test("Display the DebugMenu correctly in a ActionDialog if debug mode is enabled", async () => {
debugRegistry.category("default").add("global", () => {
return {
type: "item",
description: "Global 1",
callback: () => {
expect.step("callback global_1");
},
sequence: 0,
};
});
debugRegistry
.category("custom")
.add("item1", () => {
return {
type: "item",
description: "Item 1",
callback: () => {
expect.step("callback item_1");
},
sequence: 10,
};
})
.add("item2", ({ customKey }) => {
return {
type: "item",
description: "Item 2",
callback: () => {
expect.step("callback item_2");
expect(customKey).toBe("abc");
},
sequence: 20,
};
});
class WithCustom extends ActionDialog {
setup() {
super.setup(...arguments);
useDebugCategory("custom", { customKey: "abc" });
}
}
serverState.debug = "1";
const env = await makeDialogMockEnv();
await mountWithCleanup(WithCustom, {
env,
props: { close: () => {} },
});
expect(".o_dialog").toHaveCount(1);
expect(".o_dialog .o_debug_manager .fa-bug").toHaveCount(1);
await contains(".o_dialog .o_debug_manager button").click();
expect(".dropdown-menu .dropdown-item").toHaveCount(2);
// Check that global debugManager elements are not displayed (global_1)
const items = queryAll(".dropdown-menu .dropdown-item");
expect(queryAllTexts(items)).toEqual(["Item 1", "Item 2"]);
for (const item of items) {
await click(item);
}
expect.verifySteps(["callback item_1", "callback item_2"]);
});
test("can regenerate assets bundles", async () => {
patchWithCleanup(browser.location, {
reload: () => expect.step("reloadPage"),
});
debugRegistry.category("default").add("regenerateAssets", regenerateAssets);
await mountWithCleanup(DebugMenuParent);
await contains("button.dropdown-toggle").click();
expect(".dropdown-menu .dropdown-item").toHaveCount(1);
const item = queryOne(".dropdown-menu .dropdown-item");
expect(item).toHaveText("Regenerate Assets");
await click(item);
await animationFrame();
expect.verifySteps(["ir.attachment/regenerate_assets_bundles", "reloadPage"]);
});
test("cannot acess the Become superuser menu if not admin", async () => {
debugRegistry.category("default").add("becomeSuperuser", becomeSuperuser);
user.isAdmin = false;
await mountWithCleanup(DebugMenuParent);
await contains("button.dropdown-toggle").click();
expect(".dropdown-menu .dropdown-item").toHaveCount(0);
});
test("can open a view", async () => {
serverState.debug = "1";
webModels.IrUiView._views.list = `<list><field name="name"/><field name="type"/></list>`;
webModels.ResPartner._views["form,1"] = `<form><div class="some_view"/></form>`;
webModels.IrUiView._records.push({
id: 1,
name: "formView",
model: "res.partner",
type: "form",
active: true,
});
defineWebModels();
registry.category("debug").category("default").add("openViewItem", openViewItem);
await mountWithCleanup(WebClient);
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item").click();
expect(".modal .o_list_view").toHaveCount(1);
await contains(".modal .o_list_view .o_data_row td").click();
expect(".modal").toHaveCount(0);
expect(".some_view").toHaveCount(1);
});
test("get view: basic rendering", async () => {
serverState.debug = "1";
webModels.ResPartner._views.list = `<list><field name="name"/></list>`;
defineWebModels();
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partners",
res_model: "res.partner",
type: "ir.actions.act_window",
views: [[false, "list"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains('Computed Arch')").click();
expect(".modal").toHaveCount(1);
expect(".modal-body").toHaveText(`<list><field name="name"/></list>`);
});
test("can edit a pivot view", async () => {
serverState.debug = "1";
webModels.ResPartner._views["pivot,18"] = "<pivot></pivot>";
webModels.IrUiView._records.push({ id: 18, name: "Edit View" });
webModels.IrUiView._views.form = `<form><field name="id"/></form>`;
defineWebModels();
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partners",
res_model: "res.partner",
type: "ir.actions.act_window",
views: [[false, "pivot"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains('View: Pivot')").click();
expect(".breadcrumb-item").toHaveCount(1);
expect(".o_breadcrumb .active").toHaveCount(1);
expect(".o_breadcrumb .active").toHaveText("Edit View");
expect(".o_field_widget[name=id]").toHaveText("18");
await click(".breadcrumb .o_back_button");
await animationFrame();
expect(".o_breadcrumb .active").toHaveCount(1);
expect(".o_breadcrumb .active").toHaveText("Partners");
});
test("can edit a search view", async () => {
serverState.debug = "1";
webModels.ResPartner._views.list = `<list><field name="id"/></list>`;
webModels.ResPartner._views["search,293"] = "<search></search>";
webModels.IrUiView._records.push({ id: 293, name: "Edit View" });
webModels.IrUiView._views.form = `<form><field name="id"/></form>`;
defineWebModels();
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partners",
res_model: "res.partner",
type: "ir.actions.act_window",
search_view_id: [293, "some_search_view"],
views: [[false, "list"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains('SearchView')").click();
expect(".breadcrumb-item").toHaveCount(1);
expect(".o_breadcrumb .active").toHaveCount(1);
expect(".o_breadcrumb .active").toHaveText("Edit View");
expect(".o_field_widget[name=id]").toHaveText("293");
});
test("edit search view on action without search_view_id", async () => {
serverState.debug = "1";
webModels.ResPartner._views.list = `<list><field name="id"/></list>`;
webModels.ResPartner._views["search,293"] = "<search></search>";
webModels.IrUiView._records.push({ id: 293, name: "Edit View" });
webModels.IrUiView._views.form = `<form><field name="id"/></form>`;
defineWebModels();
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partners",
res_model: "res.partner",
type: "ir.actions.act_window",
search_view_id: false,
views: [[false, "list"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains('SearchView')").click();
expect(".breadcrumb-item").toHaveCount(1);
expect(".o_breadcrumb .active").toHaveCount(1);
expect(".o_breadcrumb .active").toHaveText("Edit View");
expect(".o_field_widget[name=id]").toHaveText("293");
});
test("cannot edit the control panel of a form view contained in a dialog without control panel.", async () => {
serverState.debug = "1";
webModels.ResPartner._views.form = `<form><field name="id"/></form>`;
defineWebModels();
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Create a Partner",
res_model: "res.partner",
type: "ir.actions.act_window",
target: "new",
views: [[false, "form"]],
});
await contains(".o_dialog .o_debug_manager button").click();
expect(".dropdown-menu .dropdown-item:contains('SearchView')").toHaveCount(0);
});
test("set defaults: basic rendering", async () => {
serverState.debug = "1";
webModels.ResPartner._views["form,24"] = `
<form>
<field name="name"/>
</form>`;
webModels.ResPartner._records.push({ id: 1000, name: "p1" });
webModels.IrUiView._records.push({ id: 24 });
defineWebModels();
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partners",
res_model: "res.partner",
res_id: 1000,
type: "ir.actions.act_window",
views: [[24, "form"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
expect(".modal").toHaveCount(1);
expect(".modal select#formview_default_fields").toHaveCount(1);
expect(".modal #formview_default_fields option").toHaveCount(2);
expect(".modal #formview_default_fields option").toHaveCount(2);
expect(".modal #formview_default_fields option:nth-child(1)").toHaveText("");
expect(".modal #formview_default_fields option:nth-child(2)").toHaveText("Name = p1");
});
test("set defaults: click close", async () => {
serverState.debug = "1";
onRpc("ir.default", "set", async () => {
throw new Error("should not create a default");
});
webModels.ResPartner._views["form,25"] = `
<form>
<field name="name"/>
</form>`;
webModels.ResPartner._records.push({ id: 1001, name: "p1" });
webModels.IrUiView._records.push({ id: 25 });
defineWebModels();
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partners",
res_model: "res.partner",
res_id: 1001,
type: "ir.actions.act_window",
views: [[25, "form"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
expect(".modal").toHaveCount(1);
await contains(".modal .modal-footer button").click();
expect(".modal").toHaveCount(0);
});
test("set defaults: select and save", async () => {
expect.assertions(3);
serverState.debug = "1";
onRpc("ir.default", "set", async (args) => {
expect(args.args).toEqual(["res.partner", "name", "p1", true, true, false]);
return true;
});
webModels.ResPartner._views["form,26"] = `
<form>
<field name="name"/>
</form>`;
webModels.ResPartner._records.push({ id: 1002, name: "p1" });
webModels.IrUiView._records.push({ id: 26 });
defineWebModels();
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partners",
res_model: "res.partner",
res_id: 1002,
type: "ir.actions.act_window",
views: [[26, "form"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
expect(".modal").toHaveCount(1);
await contains(".modal #formview_default_fields").select("name");
await contains(".modal .modal-footer button:nth-child(2)").click();
expect(".modal").toHaveCount(0);
});
test("fetch raw data: basic rendering", async () => {
serverState.debug = "1";
class Custom extends models.Model {
_name = "custom";
name = fields.Char();
raw = fields.Binary();
properties = fields.Properties({
string: "Properties",
definition_record: "product_id",
definition_record_field: "definitions",
});
definitions = fields.PropertiesDefinition({
string: "Definitions",
});
_records = [
{
id: 1,
name: "custom1",
raw: "<raw>",
properties: [
{
name: "bd6404492c244cff",
string: "test",
type: "char",
},
],
definitions: [{ name: "xphone_prop_1", string: "P1", type: "boolean" }],
},
];
}
defineWebModels();
defineModels([Custom]);
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Custom",
res_model: "custom",
res_id: 1,
type: "ir.actions.act_window",
views: [[false, "form"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains(/^Data/)").click();
expect(".modal").toHaveCount(1);
const data = queryText(".modal-body pre");
const modalObj = JSON.parse(data);
expect(modalObj).toInclude("create_date");
expect(modalObj).toInclude("write_date");
expect(modalObj).not.toInclude("raw");
const expectedObj = {
display_name: "custom1",
id: 1,
name: "custom1",
properties: false,
definitions: [
{
name: "xphone_prop_1",
string: "P1",
type: "boolean",
},
],
};
expect(modalObj).toMatchObject(expectedObj);
});
test("view metadata: basic rendering", async () => {
serverState.debug = "1";
onRpc("get_metadata", async () => {
return [
{
create_date: "2023-01-26 14:12:10",
create_uid: [4, "Some user"],
id: 1003,
noupdate: false,
write_date: "2023-01-26 14:13:31",
write_uid: [6, "Another User"],
xmlid: "abc.partner_16",
xmlids: [{ xmlid: "abc.partner_16", noupdate: false }],
},
];
});
webModels.ResPartner._records.push({ id: 1003, name: "p1" });
defineWebModels();
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partner",
res_model: "res.partner",
res_id: 1003,
type: "ir.actions.act_window",
views: [[false, "form"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains('Metadata')").click();
expect(".modal").toHaveCount(1);
const contentModal = queryAll(".modal-body table tr th, .modal-body table tr td");
expect(queryAllTexts(contentModal)).toEqual([
"ID:",
"1003",
"XML ID:",
"abc.partner_16",
"No Update:",
"false (change)",
"Creation User:",
"Some user",
"Creation Date:",
"01/26/2023 15:12:10",
"Latest Modification by:",
"Another User",
"Latest Modification Date:",
"01/26/2023 15:13:31",
]);
});
test("set defaults: setting default value for datetime field", async () => {
serverState.debug = "1";
const argSteps = [];
onRpc("ir.default", "set", async (args) => {
argSteps.push(args.args);
return true;
});
class Partner extends models.Model {
_name = "partner";
datetime = fields.Datetime();
reference = fields.Reference({ selection: [["pony", "Pony"]] });
m2o = fields.Many2one({ relation: "pony" });
_records = [
{
id: 1,
display_name: "p1",
datetime: "2024-01-24 16:46:16",
reference: "pony,1",
m2o: 1,
},
];
_views = {
"form,18": /* xml */ `
<form>
<field name="datetime"/>
<field name="reference"/>
<field name="m2o"/>
</form>
`,
};
}
class Pony extends models.Model {
_name = "pony";
_records = [{ id: 1 }];
}
class IrUiView extends models.Model {
_name = "ir.ui.view";
name = fields.Char();
model = fields.Char();
_records = [{ id: 18 }];
}
defineModels([Partner, Pony, IrUiView]);
await mountWithCleanup(WebClient);
for (const field_name of ["datetime", "reference", "m2o"]) {
await getService("action").doAction({
name: "Partners",
res_model: "partner",
res_id: 1,
type: "ir.actions.act_window",
views: [[18, "form"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
expect(".modal").toHaveCount(1);
await contains(".modal #formview_default_fields").select(field_name);
await contains(".modal .modal-footer button:nth-child(2)").click();
expect(".modal").toHaveCount(0);
}
expect(argSteps).toEqual([
["partner", "datetime", "2024-01-24 16:46:16", true, true, false],
[
"partner",
"reference",
{ resId: 1, resModel: "pony", displayName: "pony,1" },
true,
true,
false,
],
["partner", "m2o", 1, true, true, false],
]);
});
test("display model view in developer tools", async () => {
serverState.debug = "1";
webModels.ResPartner._views.form = `<form><field name="name"/></form>`;
webModels.ResPartner._records.push({ id: 88, name: "p1" });
webModels.IrModel._views.form = `
<form>
<field name="name"/>
<field name="model"/>
</form>`;
defineWebModels();
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partners",
res_model: "res.partner",
type: "ir.actions.act_window",
views: [[false, "form"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains('Model:')").click();
expect(".breadcrumb-item").toHaveCount(1);
expect(".o_breadcrumb .active").toHaveCount(1);
expect(".o_breadcrumb .active").toHaveText("Partner");
});
test("set defaults: settings default value with a very long value", async () => {
serverState.debug = "1";
const fooValue = "12".repeat(250);
const argSteps = [];
onRpc("ir.default", "set", async (args) => {
argSteps.push(args.args);
return true;
});
class Partner extends models.Model {
_name = "partner";
foo = fields.Char();
description = fields.Html();
bar = fields.Many2one({ relation: "ir.ui.view" });
_records = [
{
id: 1,
display_name: "p1",
foo: fooValue,
description: fooValue,
bar: 18,
},
];
_views = {
form: `
<form>
<field name="foo"/>
<field name="description"/>
<field name="bar" invisible="1"/>
</form>`,
};
}
class IrUiView extends models.Model {
_name = "ir.ui.view";
name = fields.Char();
model = fields.Char();
_records = [{ id: 18 }];
}
defineModels([Partner, IrUiView]);
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partners",
res_model: "partner",
res_id: 1,
type: "ir.actions.act_window",
views: [[false, "form"]],
});
await contains(".o_debug_manager button").click();
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
expect(".modal").toHaveCount(1);
expect(queryAllTexts`.modal #formview_default_fields option`).toEqual([
"",
"Foo = 121212121212121212121212121212121212121212121212121212121...",
"Description = 121212121212121212121212121212121212121212121212121212121...",
]);
expect(queryAllProperties(".modal #formview_default_fields option", "value")).toEqual([
"",
"foo",
"description",
]);
await contains(".modal #formview_default_fields").select("foo");
await contains(".modal .modal-footer button:nth-child(2)").click();
expect(".modal").toHaveCount(0);
expect(argSteps).toEqual([["partner", "foo", fooValue, true, true, false]]);
});
});

View file

@ -0,0 +1,77 @@
import { test, expect, beforeEach } from "@odoo/hoot";
import { runAllTimers } from "@odoo/hoot-mock";
import { fields, models, defineModels, mountView } from "@web/../tests/web_test_helpers";
beforeEach(() => {
const qweb = JSON.stringify([
{
exec_context: [],
results: {
archs: {
1: `<t name="Test1" t-name="test">
<t t-call-assets="web.assets_tests"/>
</t>`,
},
data: [
{
delay: 0.1,
directive: 't-call-assets="web.assets_tests"',
query: 9,
view_id: 1,
xpath: "/t/t",
},
],
},
stack: [],
start: 42,
},
]);
class Custom extends models.Model {
_name = "custom";
qweb = fields.Text();
_records = [{ qweb }];
}
class View extends models.Model {
_name = "ir.ui.view";
name = fields.Char();
model = fields.Char();
type = fields.Char();
_records = [
{
id: 1,
name: "formView",
model: "custom",
type: "form",
},
];
}
defineModels([Custom, View]);
});
test("profiling qweb view field renders delay and query", async function (assert) {
await mountView({
resModel: "custom",
type: "form",
resId: 1,
arch: `
<form>
<field name="qweb" widget="profiling_qweb_view"/>
</form>`,
});
await runAllTimers();
expect("[name='qweb'] .ace_gutter .ace_gutter-cell").toHaveCount(3);
expect("[name='qweb'] .ace_gutter .ace_gutter-cell .o_info").toHaveCount(1);
expect("[name='qweb'] .ace_gutter .ace_gutter-cell .o_info .o_delay").toHaveText("0.1");
expect("[name='qweb'] .ace_gutter .ace_gutter-cell .o_info .o_query").toHaveText("9");
expect("[name='qweb'] .o_select_view_profiling .o_delay").toHaveText("0.1 ms");
expect("[name='qweb'] .o_select_view_profiling .o_query").toHaveText("9 query");
});

View file

@ -0,0 +1,427 @@
import { destroy, expect, test } from "@odoo/hoot";
import { keyDown, keyUp, press, queryAllTexts, queryOne, resize } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, onMounted, useState, xml } from "@odoo/owl";
import {
contains,
getService,
makeDialogMockEnv,
mountWithCleanup,
} from "@web/../tests/web_test_helpers";
import { Dialog } from "@web/core/dialog/dialog";
import { useService } from "@web/core/utils/hooks";
test("simple rendering", async () => {
expect.assertions(8);
class Parent extends Component {
static components = { Dialog };
static template = xml`
<Dialog title="'Wow(l) Effect'">
Hello!
</Dialog>
`;
static props = ["*"];
}
await makeDialogMockEnv();
await mountWithCleanup(Parent);
expect(".o_dialog").toHaveCount(1);
expect(".o_dialog header .modal-title").toHaveCount(1, {
message: "the header is rendered by default",
});
expect("header .modal-title").toHaveText("Wow(l) Effect");
expect(".o_dialog main").toHaveCount(1, { message: "a dialog has always a main node" });
expect("main").toHaveText("Hello!");
expect(".o_dialog footer").toHaveCount(1, { message: "the footer is rendered by default" });
expect(".o_dialog footer button").toHaveCount(1, {
message: "the footer is rendered with a single button 'Ok' by default",
});
expect("footer button").toHaveText("Ok");
});
test("hotkeys work on dialogs", async () => {
class Parent extends Component {
static components = { Dialog };
static template = xml`
<Dialog title="'Wow(l) Effect'">
Hello!
</Dialog>
`;
static props = ["*"];
}
await makeDialogMockEnv({
dialogData: {
close: () => expect.step("close"),
dismiss: () => expect.step("dismiss"),
},
});
await mountWithCleanup(Parent);
expect("header .modal-title").toHaveText("Wow(l) Effect");
expect("footer button").toHaveText("Ok");
// Same effect as clicking on the x button
await press("escape");
await animationFrame();
expect.verifySteps(["dismiss", "close"]);
// Same effect as clicking on the Ok button
await keyDown("control+enter");
await keyUp("ctrl+enter");
expect.verifySteps(["close"]);
});
test("simple rendering with two dialogs", async () => {
expect.assertions(3);
class Parent extends Component {
static template = xml`
<div>
<Dialog title="'First Title'">
Hello!
</Dialog>
<Dialog title="'Second Title'">
Hello again!
</Dialog>
</div>
`;
static props = ["*"];
static components = { Dialog };
}
await makeDialogMockEnv();
await mountWithCleanup(Parent);
expect(".o_dialog").toHaveCount(2);
expect(queryAllTexts("header .modal-title")).toEqual(["First Title", "Second Title"]);
expect(queryAllTexts(".o_dialog .modal-body")).toEqual(["Hello!", "Hello again!"]);
});
test("click on the button x triggers the service close", async () => {
expect.assertions(2);
class Parent extends Component {
static template = xml`
<Dialog>
Hello!
</Dialog>
`;
static props = ["*"];
static components = { Dialog };
}
await makeDialogMockEnv({
dialogData: {
close: () => expect.step("close"),
dismiss: () => expect.step("dismiss"),
},
});
await mountWithCleanup(Parent);
expect(".o_dialog").toHaveCount(1);
await contains(".o_dialog header button[aria-label='Close']").click();
expect.verifySteps(["dismiss", "close"]);
});
test("click on the button x triggers the close and dismiss defined by a Child component", async () => {
expect.assertions(2);
class Child extends Component {
static template = xml`<div>Hello</div>`;
static props = ["*"];
setup() {
this.env.dialogData.close = () => expect.step("close");
this.env.dialogData.dismiss = () => expect.step("dismiss");
this.env.dialogData.scrollToOrigin = () => {};
}
}
class Parent extends Component {
static template = xml`
<Dialog>
<Child/>
</Dialog>
`;
static props = ["*"];
static components = { Child, Dialog };
}
await makeDialogMockEnv();
await mountWithCleanup(Parent);
expect(".o_dialog").toHaveCount(1);
await contains(".o_dialog header button[aria-label='Close']").click();
expect.verifySteps(["dismiss", "close"]);
});
test("click on the default footer button triggers the service close", async () => {
expect.assertions(2);
class Parent extends Component {
static template = xml`
<Dialog>
Hello!
</Dialog>
`;
static props = ["*"];
static components = { Dialog };
}
await makeDialogMockEnv({
dialogData: {
close: () => expect.step("close"),
dismiss: () => expect.step("dismiss"),
},
});
await mountWithCleanup(Parent);
expect(".o_dialog").toHaveCount(1);
await contains(".o_dialog footer button").click();
expect.verifySteps(["close"]);
});
test("render custom footer buttons is possible", async () => {
expect.assertions(2);
class SimpleButtonsDialog extends Component {
static components = { Dialog };
static template = xml`
<Dialog>
content
<t t-set-slot="footer">
<div>
<button class="btn btn-primary">The First Button</button>
<button class="btn btn-primary">The Second Button</button>
</div>
</t>
</Dialog>
`;
static props = ["*"];
}
class Parent extends Component {
static template = xml`
<div>
<SimpleButtonsDialog/>
</div>
`;
static props = ["*"];
static components = { SimpleButtonsDialog };
setup() {
super.setup();
this.state = useState({
displayDialog: true,
});
}
}
await makeDialogMockEnv();
await mountWithCleanup(Parent);
expect(".o_dialog").toHaveCount(1);
expect(".o_dialog footer button").toHaveCount(2);
});
test("embed an arbitrary component in a dialog is possible", async () => {
expect.assertions(4);
class SubComponent extends Component {
static template = xml`
<div class="o_subcomponent" t-esc="props.text" t-on-click="_onClick"/>
`;
static props = ["*"];
_onClick() {
expect.step("subcomponent-clicked");
this.props.onClicked();
}
}
class Parent extends Component {
static components = { Dialog, SubComponent };
static template = xml`
<Dialog>
<SubComponent text="'Wow(l) Effect'" onClicked="_onSubcomponentClicked"/>
</Dialog>
`;
static props = ["*"];
_onSubcomponentClicked() {
expect.step("message received by parent");
}
}
await makeDialogMockEnv();
await mountWithCleanup(Parent);
expect(".o_dialog").toHaveCount(1);
expect(".o_dialog main .o_subcomponent").toHaveCount(1);
expect(".o_subcomponent").toHaveText("Wow(l) Effect");
await contains(".o_subcomponent").click();
expect.verifySteps(["subcomponent-clicked", "message received by parent"]);
});
test("dialog without header/footer", async () => {
expect.assertions(4);
class Parent extends Component {
static components = { Dialog };
static template = xml`
<Dialog header="false" footer="false">content</Dialog>
`;
static props = ["*"];
}
await makeDialogMockEnv();
await mountWithCleanup(Parent);
expect(".o_dialog").toHaveCount(1);
expect(".o_dialog header").toHaveCount(0);
expect("main").toHaveCount(1, { message: "a dialog has always a main node" });
expect(".o_dialog footer").toHaveCount(0);
});
test("dialog size can be chosen", async () => {
expect.assertions(5);
class Parent extends Component {
static template = xml`
<div>
<Dialog contentClass="'xl'" size="'xl'">content</Dialog>
<Dialog contentClass="'lg'">content</Dialog>
<Dialog contentClass="'md'" size="'md'">content</Dialog>
<Dialog contentClass="'sm'" size="'sm'">content</Dialog>
</div>
`;
static props = ["*"];
static components = { Dialog };
}
await makeDialogMockEnv();
await mountWithCleanup(Parent);
expect(".o_dialog").toHaveCount(4);
expect(".o_dialog .modal-dialog.modal-xl .xl").toHaveCount(1);
expect(".o_dialog .modal-dialog.modal-lg .lg").toHaveCount(1);
expect(".o_dialog .modal-dialog.modal-md .md").toHaveCount(1);
expect(".o_dialog .modal-dialog.modal-sm .sm").toHaveCount(1);
});
test("dialog can be rendered on fullscreen", async () => {
expect.assertions(2);
class Parent extends Component {
static template = xml`
<Dialog fullscreen="true">content</Dialog>
`;
static props = ["*"];
static components = { Dialog };
}
await makeDialogMockEnv();
await mountWithCleanup(Parent);
expect(".o_dialog").toHaveCount(1);
expect(".o_dialog .modal").toHaveClass("o_modal_full");
});
test("can be the UI active element", async () => {
expect.assertions(4);
class Parent extends Component {
static template = xml`<Dialog>content</Dialog>`;
static props = ["*"];
static components = { Dialog };
setup() {
this.ui = useService("ui");
expect(this.ui.activeElement).toBe(document, {
message:
"UI active element should be the default (document) as Parent is not mounted yet",
});
onMounted(() => {
expect(".modal").toHaveCount(1);
expect(this.ui.activeElement).toBe(
queryOne(".modal", { message: "UI active element should be the dialog modal" })
);
});
}
}
await makeDialogMockEnv();
const parent = await mountWithCleanup(Parent);
destroy(parent);
await Promise.resolve();
expect(getService("ui").activeElement).toBe(document, {
message: "UI owner should be reset to the default (document)",
});
});
test.tags("mobile");
test("dialog can't be moved on small screen", async () => {
class Parent extends Component {
static template = xml`<Dialog>content</Dialog>`;
static components = { Dialog };
static props = ["*"];
}
await makeDialogMockEnv();
await mountWithCleanup(Parent);
expect(".modal-content").toHaveStyle({
top: "0px",
left: "0px",
});
const header = queryOne(".modal-header");
const headerRect = header.getBoundingClientRect();
// Even if the `dragAndDrop` is called, confirms that there are no effects
await contains(header).dragAndDrop(".modal-content", {
position: {
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
// so we need to move the dialog based on these coordinates.
x: headerRect.x + headerRect.width / 2 + 20,
y: headerRect.y + headerRect.height / 2 + 50,
},
});
expect(".modal-content").toHaveStyle({
top: "0px",
left: "0px",
});
});
test.tags("desktop");
test("dialog can be moved", async () => {
class Parent extends Component {
static template = xml`<Dialog>content</Dialog>`;
static props = ["*"];
static components = { Dialog };
}
await makeDialogMockEnv();
await mountWithCleanup(Parent);
expect(".modal-content").toHaveStyle({
left: "0px",
top: "0px",
});
const modalRect = queryOne(".modal").getBoundingClientRect();
const header = queryOne(".modal-header");
const headerRect = header.getBoundingClientRect();
await contains(header).dragAndDrop(".modal-content", {
position: {
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
// so we need to move the dialog based on these coordinates.
x: headerRect.x + headerRect.width / 2 + 20,
y: headerRect.y + headerRect.height / 2 + 50,
},
});
expect(".modal-content").toHaveStyle({
left: `${modalRect.y + 20}px`,
top: `${modalRect.x + 50}px`,
});
});
test.tags("desktop");
test("dialog's position is reset on resize", async () => {
class Parent extends Component {
static template = xml`<Dialog>content</Dialog>`;
static props = ["*"];
static components = { Dialog };
}
await makeDialogMockEnv();
await mountWithCleanup(Parent);
expect(".modal-content").toHaveStyle({
left: "0px",
top: "0px",
});
const modalRect = queryOne(".modal").getBoundingClientRect();
const header = queryOne(".modal-header");
const headerRect = header.getBoundingClientRect();
await contains(header).dragAndDrop(".modal-content", {
position: {
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
// so we need to move the dialog based on these coordinates.
x: headerRect.x + headerRect.width / 2 + 20,
y: headerRect.y + headerRect.height / 2 + 50,
},
});
expect(".modal-content").toHaveStyle({
left: `${modalRect.y + 20}px`,
top: `${modalRect.x + 50}px`,
});
await resize();
await animationFrame();
expect(".modal-content").toHaveStyle({
left: "0px",
top: "0px",
});
});

View file

@ -0,0 +1,207 @@
import { test, expect, beforeEach, describe } from "@odoo/hoot";
import { click, press, queryAll, queryAllTexts, queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { getService, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { Dialog } from "@web/core/dialog/dialog";
import { Component, xml } from "@odoo/owl";
import { usePopover } from "@web/core/popover/popover_hook";
import { useAutofocus } from "@web/core/utils/hooks";
import { MainComponentsContainer } from "@web/core/main_components_container";
describe.current.tags("desktop");
beforeEach(async () => {
await mountWithCleanup(MainComponentsContainer);
});
test("Simple rendering with a single dialog", async () => {
class CustomDialog extends Component {
static components = { Dialog };
static template = xml`<Dialog title="'Welcome'">content</Dialog>`;
static props = ["*"];
}
expect(".o_dialog").toHaveCount(0);
getService("dialog").add(CustomDialog);
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Welcome");
await click(".o_dialog footer button");
await animationFrame();
expect(".o_dialog").toHaveCount(0);
});
test("Simple rendering and close a single dialog", async () => {
class CustomDialog extends Component {
static components = { Dialog };
static template = xml`<Dialog title="'Welcome'">content</Dialog>`;
static props = ["*"];
}
expect(".o_dialog").toHaveCount(0);
const removeDialog = getService("dialog").add(CustomDialog);
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Welcome");
removeDialog();
await animationFrame();
expect(".o_dialog").toHaveCount(0);
// Call a second time, the close on the dialog.
// As the dialog is already close, this call is just ignored. No error should be raised.
removeDialog();
expect(".o_dialog").toHaveCount(0);
});
test("rendering with two dialogs", async () => {
class CustomDialog extends Component {
static components = { Dialog };
static template = xml`<Dialog title="props.title">content</Dialog>`;
static props = ["*"];
}
expect(".o_dialog").toHaveCount(0);
getService("dialog").add(CustomDialog, { title: "Hello" });
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Hello");
getService("dialog").add(CustomDialog, { title: "Sauron" });
await animationFrame();
expect(".o_dialog").toHaveCount(2);
expect(queryAllTexts("header .modal-title")).toEqual(["Hello", "Sauron"]);
await click(".o_dialog footer button");
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Sauron");
});
test("multiple dialogs can become the UI active element", async () => {
class CustomDialog extends Component {
static components = { Dialog };
static template = xml`<Dialog title="props.title">content</Dialog>`;
static props = ["*"];
}
getService("dialog").add(CustomDialog, { title: "Hello" });
await animationFrame();
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
getService("ui").activeElement
);
getService("dialog").add(CustomDialog, { title: "Sauron" });
await animationFrame();
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
getService("ui").activeElement
);
getService("dialog").add(CustomDialog, { title: "Rafiki" });
await animationFrame();
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
getService("ui").activeElement
);
});
test("a popover with an autofocus child can become the UI active element", async () => {
class TestPopover extends Component {
static template = xml`<input type="text" t-ref="autofocus" />`;
static props = ["*"];
setup() {
useAutofocus();
}
}
class CustomDialog extends Component {
static components = { Dialog };
static template = xml`<Dialog title="props.title">
<button class="btn test" t-on-click="showPopover">show</button>
</Dialog>`;
static props = ["*"];
setup() {
this.popover = usePopover(TestPopover);
}
showPopover(event) {
this.popover.open(event.target, {});
}
}
expect(document).toBe(getService("ui").activeElement);
expect(document.body).toBeFocused();
getService("dialog").add(CustomDialog, { title: "Hello" });
await animationFrame();
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
getService("ui").activeElement
);
expect(".btn.test").toBeFocused();
await click(".btn.test");
await animationFrame();
expect(queryOne(".o_popover")).toBe(getService("ui").activeElement);
expect(".o_popover input").toBeFocused();
});
test("Interactions between multiple dialogs", async () => {
function activity(modals) {
const active = [];
const names = [];
for (let i = 0; i < modals.length; i++) {
active[i] = !modals[i].classList.contains("o_inactive_modal");
names[i] = modals[i].querySelector(".modal-title").textContent;
}
return { active, names };
}
class CustomDialog extends Component {
static components = { Dialog };
static template = xml`<Dialog title="props.title">content</Dialog>`;
static props = ["*"];
}
getService("dialog").add(CustomDialog, { title: "Hello" });
await animationFrame();
getService("dialog").add(CustomDialog, { title: "Sauron" });
await animationFrame();
getService("dialog").add(CustomDialog, { title: "Rafiki" });
await animationFrame();
expect(".o_dialog").toHaveCount(3);
let res = activity(queryAll(".o_dialog"));
expect(res.active).toEqual([false, false, true]);
expect(res.names).toEqual(["Hello", "Sauron", "Rafiki"]);
await press("Escape", { bubbles: true });
await animationFrame();
expect(".o_dialog").toHaveCount(2);
res = activity(queryAll(".o_dialog"));
expect(res.active).toEqual([false, true]);
expect(res.names).toEqual(["Hello", "Sauron"]);
await click(".o_dialog:not(.o_inactive_modal) footer button");
await animationFrame();
expect(".o_dialog").toHaveCount(1);
res = activity(queryAll(".o_dialog"));
expect(res.active).toEqual([true]);
expect(res.names).toEqual(["Hello"]);
await click("footer button");
await animationFrame();
expect(".o_dialog").toHaveCount(0);
});
test("dialog component crashes", async () => {
expect.errors(1);
class FailingDialog extends Component {
static components = { Dialog };
static template = xml`<Dialog title="'Error'">content</Dialog>`;
static props = ["*"];
setup() {
throw new Error("Some Error");
}
}
getService("dialog").add(FailingDialog);
await animationFrame();
expect(".modal .o_error_dialog").toHaveCount(1);
expect.verifyErrors(["Error: Some Error"]);
});

View file

@ -0,0 +1,589 @@
import { describe, expect, test } from "@odoo/hoot";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
import { Domain } from "@web/core/domain";
import { PyDate } from "@web/core/py_js/py_date";
describe.current.tags("headless");
//-------------------------------------------------------------------------
// Basic properties
//-------------------------------------------------------------------------
describe("Basic Properties", () => {
test("empty", () => {
expect(new Domain([]).contains({})).toBe(true);
expect(new Domain([]).toString()).toBe("[]");
expect(new Domain([]).toList()).toEqual([]);
});
test("undefined domain", () => {
expect(new Domain(undefined).contains({})).toBe(true);
expect(new Domain(undefined).toString()).toBe("[]");
expect(new Domain(undefined).toList()).toEqual([]);
});
test("simple condition", () => {
expect(new Domain([["a", "=", 3]]).contains({ a: 3 })).toBe(true);
expect(new Domain([["a", "=", 3]]).contains({ a: 5 })).toBe(false);
expect(new Domain([["a", "=", 3]]).toString()).toBe(`[("a", "=", 3)]`);
expect(new Domain([["a", "=", 3]]).toList()).toEqual([["a", "=", 3]]);
});
test("can be created from domain", () => {
const domain = new Domain([["a", "=", 3]]);
expect(new Domain(domain).toString()).toBe(`[("a", "=", 3)]`);
});
test("basic", () => {
const record = {
a: 3,
group_method: "line",
select1: "day",
rrule_type: "monthly",
};
expect(new Domain([["a", "=", 3]]).contains(record)).toBe(true);
expect(new Domain([["a", "=", 5]]).contains(record)).toBe(false);
expect(new Domain([["group_method", "!=", "count"]]).contains(record)).toBe(true);
expect(
new Domain([
["select1", "=", "day"],
["rrule_type", "=", "monthly"],
]).contains(record)
).toBe(true);
});
test("support of '=?' operator", () => {
const record = { a: 3 };
expect(new Domain([["a", "=?", null]]).contains(record)).toBe(true);
expect(new Domain([["a", "=?", false]]).contains(record)).toBe(true);
expect(new Domain(["!", ["a", "=?", false]]).contains(record)).toBe(false);
expect(new Domain([["a", "=?", 1]]).contains(record)).toBe(false);
expect(new Domain([["a", "=?", 3]]).contains(record)).toBe(true);
expect(new Domain(["!", ["a", "=?", 3]]).contains(record)).toBe(false);
});
test("or", () => {
const currentDomain = [
"|",
["section_id", "=", 42],
"|",
["user_id", "=", 3],
["member_ids", "in", [3]],
];
const record = {
section_id: null,
user_id: null,
member_ids: null,
};
expect(new Domain(currentDomain).contains({ ...record, section_id: 42 })).toBe(true);
expect(new Domain(currentDomain).contains({ ...record, user_id: 3 })).toBe(true);
expect(new Domain(currentDomain).contains({ ...record, member_ids: 3 })).toBe(true);
});
test("and", () => {
const domain = new Domain(["&", "&", ["a", "=", 1], ["b", "=", 2], ["c", "=", 3]]);
expect(domain.contains({ a: 1, b: 2, c: 3 })).toBe(true);
expect(domain.contains({ a: -1, b: 2, c: 3 })).toBe(false);
expect(domain.contains({ a: 1, b: -1, c: 3 })).toBe(false);
expect(domain.contains({ a: 1, b: 2, c: -1 })).toBe(false);
});
test("not", () => {
const record = {
a: 5,
group_method: "line",
};
expect(new Domain(["!", ["a", "=", 3]]).contains(record)).toBe(true);
expect(new Domain(["!", ["group_method", "=", "count"]]).contains(record)).toBe(true);
});
test("like, =like, ilike, =ilike, not like and not ilike", () => {
expect.assertions(28);
expect(new Domain([["a", "like", "value"]]).contains({ a: "value" })).toBe(true);
expect(new Domain([["a", "like", "value"]]).contains({ a: "some value" })).toBe(true);
expect(new Domain([["a", "like", "value"]]).contains({ a: "Some Value" })).not.toBe(true);
expect(new Domain([["a", "like", "value"]]).contains({ a: false })).toBe(false);
expect(new Domain([["a", "=like", "%value"]]).contains({ a: "value" })).toBe(true);
expect(new Domain([["a", "=like", "%value"]]).contains({ a: "some value" })).toBe(true);
expect(new Domain([["a", "=like", "%value"]]).contains({ a: "Some Value" })).not.toBe(true);
expect(new Domain([["a", "=like", "%value"]]).contains({ a: false })).toBe(false);
expect(new Domain([["a", "ilike", "value"]]).contains({ a: "value" })).toBe(true);
expect(new Domain([["a", "ilike", "value"]]).contains({ a: "some value" })).toBe(true);
expect(new Domain([["a", "ilike", "value"]]).contains({ a: "Some Value" })).toBe(true);
expect(new Domain([["a", "ilike", "value"]]).contains({ a: false })).toBe(false);
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: "value" })).toBe(true);
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: "some value" })).toBe(true);
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: "Some Value" })).toBe(true);
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: false })).toBe(false);
expect(new Domain([["a", "not like", "value"]]).contains({ a: "value" })).not.toBe(true);
expect(new Domain([["a", "not like", "value"]]).contains({ a: "some value" })).not.toBe(
true
);
expect(new Domain([["a", "not like", "value"]]).contains({ a: "Some Value" })).toBe(true);
expect(new Domain([["a", "not like", "value"]]).contains({ a: "something" })).toBe(true);
expect(new Domain([["a", "not like", "value"]]).contains({ a: "Something" })).toBe(true);
expect(new Domain([["a", "not like", "value"]]).contains({ a: false })).toBe(false);
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "value" })).not.toBe(true);
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "some value" })).toBe(false);
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "Some Value" })).toBe(false);
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "something" })).toBe(true);
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "Something" })).toBe(true);
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: false })).toBe(false);
});
test("complex domain", () => {
const domain = new Domain(["&", "!", ["a", "=", 1], "|", ["a", "=", 2], ["a", "=", 3]]);
expect(domain.contains({ a: 1 })).toBe(false);
expect(domain.contains({ a: 2 })).toBe(true);
expect(domain.contains({ a: 3 })).toBe(true);
expect(domain.contains({ a: 4 })).toBe(false);
});
test("toList", () => {
expect(new Domain([]).toList()).toEqual([]);
expect(new Domain([["a", "=", 3]]).toList()).toEqual([["a", "=", 3]]);
expect(
new Domain([
["a", "=", 3],
["b", "!=", "4"],
]).toList()
).toEqual(["&", ["a", "=", 3], ["b", "!=", "4"]]);
expect(new Domain(["!", ["a", "=", 3]]).toList()).toEqual(["!", ["a", "=", 3]]);
});
test("toString", () => {
expect(new Domain([]).toString()).toBe(`[]`);
expect(new Domain([["a", "=", 3]]).toString()).toBe(`[("a", "=", 3)]`);
expect(
new Domain([
["a", "=", 3],
["b", "!=", "4"],
]).toString()
).toBe(`["&", ("a", "=", 3), ("b", "!=", "4")]`);
expect(new Domain(["!", ["a", "=", 3]]).toString()).toBe(`["!", ("a", "=", 3)]`);
expect(new Domain([["name", "=", null]]).toString()).toBe('[("name", "=", None)]');
expect(new Domain([["name", "=", false]]).toString()).toBe('[("name", "=", False)]');
expect(new Domain([["name", "=", true]]).toString()).toBe('[("name", "=", True)]');
expect(new Domain([["name", "=", "null"]]).toString()).toBe('[("name", "=", "null")]');
expect(new Domain([["name", "=", "false"]]).toString()).toBe('[("name", "=", "false")]');
expect(new Domain([["name", "=", "true"]]).toString()).toBe('[("name", "=", "true")]');
expect(new Domain().toString()).toBe("[]");
expect(new Domain([["name", "in", [true, false]]]).toString()).toBe(
'[("name", "in", [True, False])]'
);
expect(new Domain([["name", "in", [null]]]).toString()).toBe('[("name", "in", [None])]');
expect(new Domain([["name", "in", ["foo", "bar"]]]).toString()).toBe(
'[("name", "in", ["foo", "bar"])]'
);
expect(new Domain([["name", "in", [1, 2]]]).toString()).toBe('[("name", "in", [1, 2])]');
expect(new Domain(["&", ["name", "=", "foo"], ["type", "=", "bar"]]).toString()).toBe(
'["&", ("name", "=", "foo"), ("type", "=", "bar")]'
);
expect(new Domain(["|", ["name", "=", "foo"], ["type", "=", "bar"]]).toString()).toBe(
'["|", ("name", "=", "foo"), ("type", "=", "bar")]'
);
expect(new Domain().toString()).toBe("[]");
// string domains are only reformatted
expect(new Domain('[("name","ilike","foo")]').toString()).toBe(
'[("name", "ilike", "foo")]'
);
});
test("toJson", () => {
expect(new Domain([]).toJson()).toEqual([]);
expect(new Domain("[]").toJson()).toEqual([]);
expect(new Domain([["a", "=", 3]]).toJson()).toEqual([["a", "=", 3]]);
expect(new Domain('[("a", "=", 3)]').toJson()).toEqual([["a", "=", 3]]);
expect(new Domain('[("user_id", "=", uid)]').toJson()).toBe('[("user_id", "=", uid)]');
expect(new Domain('[("date", "=", context_today())]').toJson()).toBe(
'[("date", "=", context_today())]'
);
});
test("implicit &", () => {
const domain = new Domain([
["a", "=", 3],
["b", "=", 4],
]);
expect(domain.contains({})).toBe(false);
expect(domain.contains({ a: 3, b: 4 })).toBe(true);
expect(domain.contains({ a: 3, b: 5 })).toBe(false);
});
test("comparison operators", () => {
expect(new Domain([["a", "=", 3]]).contains({ a: 3 })).toBe(true);
expect(new Domain([["a", "=", 3]]).contains({ a: 4 })).toBe(false);
expect(new Domain([["a", "=", 3]]).toString()).toBe(`[("a", "=", 3)]`);
expect(new Domain([["a", "==", 3]]).contains({ a: 3 })).toBe(true);
expect(new Domain([["a", "==", 3]]).contains({ a: 4 })).toBe(false);
expect(new Domain([["a", "==", 3]]).toString()).toBe(`[("a", "==", 3)]`);
expect(new Domain([["a", "!=", 3]]).contains({ a: 3 })).toBe(false);
expect(new Domain([["a", "!=", 3]]).contains({ a: 4 })).toBe(true);
expect(new Domain([["a", "!=", 3]]).toString()).toBe(`[("a", "!=", 3)]`);
expect(new Domain([["a", "<>", 3]]).contains({ a: 3 })).toBe(false);
expect(new Domain([["a", "<>", 3]]).contains({ a: 4 })).toBe(true);
expect(new Domain([["a", "<>", 3]]).toString()).toBe(`[("a", "<>", 3)]`);
expect(new Domain([["a", "<", 3]]).contains({ a: 5 })).toBe(false);
expect(new Domain([["a", "<", 3]]).contains({ a: 3 })).toBe(false);
expect(new Domain([["a", "<", 3]]).contains({ a: 2 })).toBe(true);
expect(new Domain([["a", "<", 3]]).toString()).toBe(`[("a", "<", 3)]`);
expect(new Domain([["a", "<=", 3]]).contains({ a: 5 })).toBe(false);
expect(new Domain([["a", "<=", 3]]).contains({ a: 3 })).toBe(true);
expect(new Domain([["a", "<=", 3]]).contains({ a: 2 })).toBe(true);
expect(new Domain([["a", "<=", 3]]).toString()).toBe(`[("a", "<=", 3)]`);
expect(new Domain([["a", ">", 3]]).contains({ a: 5 })).toBe(true);
expect(new Domain([["a", ">", 3]]).contains({ a: 3 })).toBe(false);
expect(new Domain([["a", ">", 3]]).contains({ a: 2 })).toBe(false);
expect(new Domain([["a", ">", 3]]).toString()).toBe(`[("a", ">", 3)]`);
expect(new Domain([["a", ">=", 3]]).contains({ a: 5 })).toBe(true);
expect(new Domain([["a", ">=", 3]]).contains({ a: 3 })).toBe(true);
expect(new Domain([["a", ">=", 3]]).contains({ a: 2 })).toBe(false);
expect(new Domain([["a", ">=", 3]]).toString()).toBe(`[("a", ">=", 3)]`);
});
test("other operators", () => {
expect(new Domain([["a", "in", 3]]).contains({ a: 3 })).toBe(true);
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 3 })).toBe(true);
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [3] })).toBe(true);
expect(new Domain([["a", "in", 3]]).contains({ a: 5 })).toBe(false);
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 5 })).toBe(false);
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [5] })).toBe(false);
expect(new Domain([["a", "not in", 3]]).contains({ a: 3 })).toBe(false);
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 3 })).toBe(false);
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [3] })).toBe(false);
expect(new Domain([["a", "not in", 3]]).contains({ a: 5 })).toBe(true);
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 5 })).toBe(true);
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [5] })).toBe(true);
expect(new Domain([["a", "like", "abc"]]).contains({ a: "abc" })).toBe(true);
expect(new Domain([["a", "like", "abc"]]).contains({ a: "def" })).toBe(false);
expect(new Domain([["a", "=like", "abc"]]).contains({ a: "abc" })).toBe(true);
expect(new Domain([["a", "=like", "abc"]]).contains({ a: "def" })).toBe(false);
expect(new Domain([["a", "ilike", "abc"]]).contains({ a: "abc" })).toBe(true);
expect(new Domain([["a", "ilike", "abc"]]).contains({ a: "def" })).toBe(false);
expect(new Domain([["a", "=ilike", "abc"]]).contains({ a: "abc" })).toBe(true);
expect(new Domain([["a", "=ilike", "abc"]]).contains({ a: "def" })).toBe(false);
});
test("creating a domain with a string expression", () => {
expect(new Domain(`[('a', '>=', 3)]`).toString()).toBe(`[("a", ">=", 3)]`);
expect(new Domain(`[('a', '>=', 3)]`).contains({ a: 5 })).toBe(true);
});
test("can evaluate a python expression", () => {
expect(new Domain(`[('date', '!=', False)]`).toList()).toEqual([["date", "!=", false]]);
expect(new Domain(`[('date', '!=', False)]`).toList()).toEqual([["date", "!=", false]]);
expect(new Domain(`[('date', '!=', 1 + 2)]`).toString()).toBe(`[("date", "!=", 1 + 2)]`);
expect(new Domain(`[('date', '!=', 1 + 2)]`).toList()).toEqual([["date", "!=", 3]]);
expect(new Domain(`[('a', '==', 1 + 2)]`).contains({ a: 3 })).toBe(true);
expect(new Domain(`[('a', '==', 1 + 2)]`).contains({ a: 2 })).toBe(false);
});
test("some expression with date stuff", () => {
patchWithCleanup(PyDate, {
today() {
return new PyDate(2013, 4, 24);
},
});
expect(
new Domain(
"[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"
).toList()
).toEqual([["date", ">=", "2013-03-25"]]);
const domainList = new Domain(
"[('date', '>=', context_today() - relativedelta(days=30))]"
).toList(); // domain creation using `parseExpr` function since the parameter is a string.
expect(domainList[0][2]).toEqual(PyDate.create({ day: 25, month: 3, year: 2013 }), {
message: "The right item in the rule in the domain should be a PyDate object",
});
expect(JSON.stringify(domainList)).toBe('[["date",">=","2013-03-25"]]');
const domainList2 = new Domain(domainList).toList(); // domain creation using `toAST` function since the parameter is a list.
expect(domainList2[0][2]).toEqual(PyDate.create({ day: 25, month: 3, year: 2013 }), {
message: "The right item in the rule in the domain should be a PyDate object",
});
expect(JSON.stringify(domainList2)).toBe('[["date",">=","2013-03-25"]]');
});
test("Check that there is no dependency between two domains", () => {
// The purpose of this test is to verify that a domain created on the basis
// of another one does not share any dependency.
const domain1 = new Domain(`[('date', '!=', False)]`);
const domain2 = new Domain(domain1);
expect(domain1.toString()).toBe(domain2.toString());
domain2.ast.value.unshift({ type: 1, value: "!" });
expect(domain1.toString()).not.toBe(domain2.toString());
});
test("TRUE and FALSE Domain", () => {
expect(Domain.TRUE.contains({})).toBe(true);
expect(Domain.FALSE.contains({})).toBe(false);
expect(Domain.and([Domain.TRUE, new Domain([["a", "=", 3]])]).contains({ a: 3 })).toBe(
true
);
expect(Domain.and([Domain.FALSE, new Domain([["a", "=", 3]])]).contains({ a: 3 })).toBe(
false
);
});
test("invalid domains should not succeed", () => {
expect(() => new Domain(["|", ["hr_presence_state", "=", "absent"]])).toThrow(
/invalid domain .* \(missing 1 segment/
);
expect(
() =>
new Domain([
"|",
"|",
["hr_presence_state", "=", "absent"],
["attendance_state", "=", "checked_in"],
])
).toThrow(/invalid domain .* \(missing 1 segment/);
expect(() => new Domain(["|", "|", ["hr_presence_state", "=", "absent"]])).toThrow(
/invalid domain .* \(missing 2 segment\(s\)/
);
expect(() => new Domain(["&", ["composition_mode", "!=", "mass_post"]])).toThrow(
/invalid domain .* \(missing 1 segment/
);
expect(() => new Domain(["!"])).toThrow(/invalid domain .* \(missing 1 segment/);
expect(() => new Domain(`[(1, 2)]`)).toThrow(/Invalid domain AST/);
expect(() => new Domain(`[(1, 2, 3, 4)]`)).toThrow(/Invalid domain AST/);
expect(() => new Domain(`["a"]`)).toThrow(/Invalid domain AST/);
expect(() => new Domain(`[1]`)).toThrow(/Invalid domain AST/);
expect(() => new Domain(`[x]`)).toThrow(/Invalid domain AST/);
expect(() => new Domain(`[True]`)).toThrow(/Invalid domain AST/); // will possibly change with CHM work
expect(() => new Domain(`[(x.=, "=", 1)]`)).toThrow(/Invalid domain representation/);
expect(() => new Domain(`[(+, "=", 1)]`)).toThrow(/Invalid domain representation/);
expect(() => new Domain([{}])).toThrow(/Invalid domain representation/);
expect(() => new Domain([1])).toThrow(/Invalid domain representation/);
});
test("follow relations", () => {
expect(
new Domain([["partner.city", "ilike", "Bru"]]).contains({
name: "Lucas",
partner: {
city: "Bruxelles",
},
})
).toBe(true);
expect(
new Domain([["partner.city.name", "ilike", "Bru"]]).contains({
name: "Lucas",
partner: {
city: {
name: "Bruxelles",
},
},
})
).toBe(true);
});
test("Arrays comparison", () => {
const domain = new Domain(["&", ["a", "==", []], ["b", "!=", []]]);
expect(domain.contains({ a: [] })).toBe(true);
expect(domain.contains({ a: [], b: [4] })).toBe(true);
expect(domain.contains({ a: [1] })).toBe(false);
expect(domain.contains({ b: [] })).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Normalization
// ---------------------------------------------------------------------------
describe("Normalization", () => {
test("return simple (normalized) domains", () => {
const domains = ["[]", `[("a", "=", 1)]`, `["!", ("a", "=", 1)]`];
for (const domain of domains) {
expect(new Domain(domain).toString()).toBe(domain);
}
});
test("properly add the & in a non normalized domain", () => {
expect(new Domain(`[("a", "=", 1), ("b", "=", 2)]`).toString()).toBe(
`["&", ("a", "=", 1), ("b", "=", 2)]`
);
});
test("normalize domain with ! operator", () => {
expect(new Domain(`["!", ("a", "=", 1), ("b", "=", 2)]`).toString()).toBe(
`["&", "!", ("a", "=", 1), ("b", "=", 2)]`
);
});
});
// ---------------------------------------------------------------------------
// Combining domains
// ---------------------------------------------------------------------------
describe("Combining domains", () => {
test("combining zero domain", () => {
expect(Domain.combine([], "AND").toString()).toBe("[]");
expect(Domain.combine([], "OR").toString()).toBe("[]");
expect(Domain.combine([], "AND").contains({ a: 1, b: 2 })).toBe(true);
});
test("combining one domain", () => {
expect(Domain.combine([`[("a", "=", 1)]`], "AND").toString()).toBe(`[("a", "=", 1)]`);
expect(Domain.combine([`[("user_id", "=", uid)]`], "AND").toString()).toBe(
`[("user_id", "=", uid)]`
);
expect(Domain.combine([[["a", "=", 1]]], "AND").toString()).toBe(`[("a", "=", 1)]`);
expect(Domain.combine(["[('a', '=', '1'), ('b', '!=', 2)]"], "AND").toString()).toBe(
`["&", ("a", "=", "1"), ("b", "!=", 2)]`
);
});
test("combining two domains", () => {
expect(Domain.combine([`[("a", "=", 1)]`, "[]"], "AND").toString()).toBe(`[("a", "=", 1)]`);
expect(Domain.combine([`[("a", "=", 1)]`, []], "AND").toString()).toBe(`[("a", "=", 1)]`);
expect(Domain.combine([new Domain(`[("a", "=", 1)]`), "[]"], "AND").toString()).toBe(
`[("a", "=", 1)]`
);
expect(Domain.combine([new Domain(`[("a", "=", 1)]`), "[]"], "OR").toString()).toBe(
`[("a", "=", 1)]`
);
expect(Domain.combine([[["a", "=", 1]], "[('uid', '<=', uid)]"], "AND").toString()).toBe(
`["&", ("a", "=", 1), ("uid", "<=", uid)]`
);
expect(Domain.combine([[["a", "=", 1]], "[('b', '<=', 3)]"], "OR").toString()).toBe(
`["|", ("a", "=", 1), ("b", "<=", 3)]`
);
expect(
Domain.combine(
["[('a', '=', '1'), ('c', 'in', [4, 5])]", "[('b', '<=', 3)]"],
"OR"
).toString()
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
expect(
Domain.combine(
[new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"), "[('b', '<=', 3)]"],
"OR"
).toString()
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
});
test("combining three domains", () => {
expect(
Domain.combine(
[
new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"),
[["b", "<=", 3]],
`['!', ('uid', '=', uid)]`,
],
"OR"
).toString()
).toBe(
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), "|", ("b", "<=", 3), "!", ("uid", "=", uid)]`
);
});
});
// ---------------------------------------------------------------------------
// OPERATOR AND / OR / NOT
// ---------------------------------------------------------------------------
describe("Operator and - or - not", () => {
test("combining two domains with and/or", () => {
expect(Domain.and([`[("a", "=", 1)]`, "[]"]).toString()).toBe(`[("a", "=", 1)]`);
expect(Domain.and([`[("a", "=", 1)]`, []]).toString()).toBe(`[("a", "=", 1)]`);
expect(Domain.and([new Domain(`[("a", "=", 1)]`), "[]"]).toString()).toBe(
`[("a", "=", 1)]`
);
expect(Domain.or([new Domain(`[("a", "=", 1)]`), "[]"]).toString()).toBe(`[("a", "=", 1)]`);
expect(Domain.and([[["a", "=", 1]], "[('uid', '<=', uid)]"]).toString()).toBe(
`["&", ("a", "=", 1), ("uid", "<=", uid)]`
);
expect(Domain.or([[["a", "=", 1]], "[('b', '<=', 3)]"]).toString()).toBe(
`["|", ("a", "=", 1), ("b", "<=", 3)]`
);
expect(
Domain.or(["[('a', '=', '1'), ('c', 'in', [4, 5])]", "[('b', '<=', 3)]"]).toString()
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
expect(
Domain.or([
new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"),
"[('b', '<=', 3)]",
]).toString()
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
});
test("apply `NOT` on a Domain", () => {
expect(Domain.not("[('a', '=', 1)]").toString()).toBe(`["!", ("a", "=", 1)]`);
expect(Domain.not('[("uid", "<=", uid)]').toString()).toBe(`["!", ("uid", "<=", uid)]`);
expect(Domain.not(new Domain("[('a', '=', 1)]")).toString()).toBe(`["!", ("a", "=", 1)]`);
expect(Domain.not(new Domain([["a", "=", 1]])).toString()).toBe(`["!", ("a", "=", 1)]`);
});
test("tuple are supported", () => {
expect(
new Domain(`(("field", "like", "string"), ("field", "like", "strOng"))`).toList()
).toEqual(["&", ["field", "like", "string"], ["field", "like", "strOng"]]);
expect(new Domain(`("!",("field", "like", "string"))`).toList()).toEqual([
"!",
["field", "like", "string"],
]);
expect(() => new Domain(`(("field", "like", "string"))`)).toThrow(/Invalid domain AST/);
expect(() => new Domain(`("&", "&", "|")`)).toThrow(/Invalid domain AST/);
expect(() => new Domain(`("&", "&", 3)`)).toThrow(/Invalid domain AST/);
});
});
describe("Remove domain leaf", () => {
test("Remove leaf in domain.", () => {
let domain = [
["start_datetime", "!=", false],
["end_datetime", "!=", false],
["sale_line_id", "!=", false],
];
const keysToRemove = ["start_datetime", "end_datetime"];
let newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
let expectedDomain = new Domain([
"&",
...Domain.TRUE.toList({}),
...Domain.TRUE.toList({}),
["sale_line_id", "!=", false],
]);
expect(newDomain.toList({})).toEqual(expectedDomain.toList({}));
domain = [
"|",
["role_id", "=", false],
"&",
["resource_id", "!=", false],
["start_datetime", "=", false],
["sale_line_id", "!=", false],
];
newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
expectedDomain = new Domain([
"|",
["role_id", "=", false],
"&",
["resource_id", "!=", false],
...Domain.TRUE.toList({}),
["sale_line_id", "!=", false],
]);
expect(newDomain.toList({})).toEqual(expectedDomain.toList({}));
domain = [
"|",
["start_datetime", "=", false],
["end_datetime", "=", false],
["sale_line_id", "!=", false],
];
newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
expectedDomain = new Domain([...Domain.TRUE.toList({}), ["sale_line_id", "!=", false]]);
expect(newDomain.toList({})).toEqual(expectedDomain.toList({}));
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,119 @@
import { describe, expect, test } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import {
Country,
Partner,
Player,
Product,
Stage,
Team,
} from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
import {
contains,
defineModels,
makeDialogMockEnv,
mountWithCleanup,
onRpc,
} from "@web/../tests/web_test_helpers";
import { DomainSelectorDialog } from "@web/core/domain_selector_dialog/domain_selector_dialog";
describe.current.tags("desktop");
async function makeDomainSelectorDialog(params = {}) {
const props = { ...params };
class Parent extends Component {
static components = { DomainSelectorDialog };
static template = xml`<DomainSelectorDialog t-props="domainSelectorProps"/>`;
static props = ["*"];
setup() {
this.domainSelectorProps = {
readonly: false,
domain: "[]",
close: () => {},
onConfirm: () => {},
...props,
resModel: "partner",
};
}
}
const env = await makeDialogMockEnv();
return mountWithCleanup(Parent, { env, props });
}
defineModels([Partner, Product, Team, Player, Country, Stage]);
test("a domain with a user context dynamic part is valid", async () => {
await makeDomainSelectorDialog({
domain: "[('foo', '=', uid)]",
onConfirm(domain) {
expect(domain).toBe("[('foo', '=', uid)]");
expect.step("confirmed");
},
});
onRpc("/web/domain/validate", () => {
expect.step("validation");
return true;
});
await contains(".o_dialog footer button").click();
expect.verifySteps(["validation", "confirmed"]);
});
test("can extend eval context", async () => {
await makeDomainSelectorDialog({
domain: "['&', ('foo', '=', uid), ('bar', '=', var)]",
context: { uid: 99, var: "true" },
onConfirm(domain) {
expect(domain).toBe("['&', ('foo', '=', uid), ('bar', '=', var)]");
expect.step("confirmed");
},
});
onRpc("/web/domain/validate", () => {
expect.step("validation");
return true;
});
await contains(".o_dialog footer button").click();
expect.verifySteps(["validation", "confirmed"]);
});
test("a domain with an unknown expression is not valid", async () => {
await makeDomainSelectorDialog({
domain: "[('foo', '=', unknown)]",
onConfirm() {
expect.step("confirmed");
},
});
onRpc("/web/domain/validate", () => {
expect.step("validation");
return true;
});
await contains(".o_dialog footer button").click();
expect.verifySteps([]);
});
test("model_field_selector should close on dialog drag", async () => {
await makeDomainSelectorDialog({
domain: "[('foo', '=', unknown)]",
});
expect(".o_model_field_selector_popover").toHaveCount(0);
await contains(".o_model_field_selector_value").click();
expect(".o_model_field_selector_popover").toHaveCount(1);
const header = queryOne(".modal-header");
const headerRect = header.getBoundingClientRect();
await contains(header).dragAndDrop(document.body, {
position: {
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
// so we need to move the dialog based on these coordinates.
x: headerRect.x + headerRect.width / 2 + 20,
y: headerRect.y + headerRect.height / 2 + 50,
},
});
await animationFrame();
expect(".o_model_field_selector_popover").toHaveCount(0);
});

View file

@ -0,0 +1,7 @@
import { SELECTORS as treeEditorSELECTORS } from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
export const SELECTORS = {
...treeEditorSELECTORS,
debugArea: ".o_domain_selector_debug_container textarea",
resetButton: ".o_domain_selector_row > button",
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,84 @@
import { test, expect } from "@odoo/hoot";
import { click, press, queryOne } from "@odoo/hoot-dom";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { AccordionItem } from "@web/core/dropdown/accordion_item";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
test("accordion can be rendered", async () => {
class Parent extends Component {
static template = xml`<AccordionItem description="'Test'" class="'text-primary'" selected="false"><h5>In accordion</h5></AccordionItem>`;
static components = { AccordionItem };
static props = ["*"];
}
await mountWithCleanup(Parent);
expect("div.o_accordion").toHaveCount(1);
expect(".o_accordion button.o_accordion_toggle").toHaveCount(1);
expect(".o_accordion_values").toHaveCount(0);
await click("button.o_accordion_toggle");
await animationFrame();
expect(".o_accordion_values").toHaveCount(1);
expect(queryOne(".o_accordion_values").innerHTML).toBe(`<h5>In accordion</h5>`);
});
test("dropdown with accordion keyboard navigation", async () => {
class Parent extends Component {
static template = xml`
<Dropdown>
<button>dropdown</button>
<t t-set-slot="content">
<DropdownItem>item 1</DropdownItem>
<AccordionItem description="'item 2'" selected="false">
<DropdownItem>item 2-1</DropdownItem>
<DropdownItem>item 2-2</DropdownItem>
</AccordionItem>
<DropdownItem>item 3</DropdownItem>
</t>
</Dropdown>
`;
static components = { Dropdown, DropdownItem, AccordionItem };
static props = ["*"];
}
await mountWithCleanup(Parent);
await click(".o-dropdown.dropdown-toggle");
await animationFrame();
expect(".dropdown-menu > .focus").toHaveCount(0);
const scenarioSteps = [
{ key: "arrowdown", expected: "item 1" },
{ key: "arrowdown", expected: "item 2" },
{ key: "arrowdown", expected: "item 3" },
{ key: "arrowdown", expected: "item 1" },
{ key: "tab", expected: "item 2" },
{ key: "enter", expected: "item 2" },
{ key: "tab", expected: "item 2-1" },
{ key: "tab", expected: "item 2-2" },
{ key: "tab", expected: "item 3" },
{ key: "tab", expected: "item 1" },
{ key: "arrowup", expected: "item 3" },
{ key: "arrowup", expected: "item 2-2" },
{ key: "arrowup", expected: "item 2-1" },
{ key: "arrowup", expected: "item 2" },
{ key: "enter", expected: "item 2" },
{ key: "arrowup", expected: "item 1" },
{ key: "shift+tab", expected: "item 3" },
{ key: "shift+tab", expected: "item 2" },
{ key: "shift+tab", expected: "item 1" },
{ key: "end", expected: "item 3" },
{ key: "home", expected: "item 1" },
];
for (let i = 0; i < scenarioSteps.length; i++) {
const step = scenarioSteps[i];
await press(step.key);
await animationFrame();
await runAllTimers();
expect(`.dropdown-menu .focus:contains(${step.expected})`).toBeFocused();
}
});

View file

@ -0,0 +1,224 @@
import { expect, test } from "@odoo/hoot";
import { click, hover, queryOne } from "@odoo/hoot-dom";
import { Deferred, animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import { getDropdownMenu, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownGroup } from "@web/core/dropdown/dropdown_group";
const DROPDOWN_MENU = ".o-dropdown--menu.dropdown-menu";
test.tags("desktop");
test("DropdownGroup: when one Dropdown is open, others with same group name can be toggled on mouse-enter", async () => {
expect.assertions(16);
const beforeOpenProm = new Deferred();
class Parent extends Component {
static components = { Dropdown, DropdownGroup };
static props = [];
static template = xml`
<div>
<div class="outside">OUTSIDE</div>
<DropdownGroup>
<Dropdown menuClass="'menu-one'">
<button class="one">One</button>
<t t-set-slot="content">
Content One
</t>
</Dropdown>
<Dropdown beforeOpen="() => this.beforeOpen()" menuClass="'menu-two'">
<button class="two">Two</button>
<t t-set-slot="content">
Content Two
</t>
</Dropdown>
<Dropdown menuClass="'menu-three'">
<button class="three">Three</button>
<t t-set-slot="content">
Content Three
</t>
</Dropdown>
</DropdownGroup>
<DropdownGroup>
<Dropdown menuClass="'menu-four'">
<button class="four">Four</button>
<t t-set-slot="content">
Content Four
</t>
</Dropdown>
</DropdownGroup>
</div>
`;
beforeOpen() {
expect.step("beforeOpen");
return beforeOpenProm;
}
}
await mountWithCleanup(Parent);
// Click on ONE
await click(queryOne(".one"));
await animationFrame();
expect.verifySteps([]);
expect(DROPDOWN_MENU).toHaveCount(1);
expect(".one").toHaveClass("show");
// Hover on TWO
await hover(".two");
await animationFrame();
expect.verifySteps(["beforeOpen"]);
expect(DROPDOWN_MENU).toHaveCount(1);
expect(".menu-two").toHaveCount(0);
beforeOpenProm.resolve();
await animationFrame();
expect(DROPDOWN_MENU).toHaveCount(1);
expect(".menu-two").toHaveCount(1);
// Hover on THREE
await hover(".three");
await animationFrame();
expect(DROPDOWN_MENU).toHaveCount(1);
expect(".menu-three").toHaveCount(1);
// Hover on FOUR (Should not open)
expect(".menu-four").toHaveCount(0);
await hover(".four");
await animationFrame();
expect(DROPDOWN_MENU).toHaveCount(1);
expect(".menu-three").toHaveCount(1);
expect(".menu-four").toHaveCount(0);
// Click on OUTSIDE
await click("div.outside");
await animationFrame();
expect(DROPDOWN_MENU).toHaveCount(0);
// Hover on ONE, TWO, THREE
await hover(".one");
await hover(".two");
await hover(".three");
await animationFrame();
expect(DROPDOWN_MENU).toHaveCount(0);
});
test.tags("desktop");
test("DropdownGroup: when non-sibling Dropdown is open, other must not be toggled on mouse-enter", async () => {
class Parent extends Component {
static template = xml`
<div>
<DropdownGroup>
<Dropdown>
<button class="one">One</button>
<t t-set-slot="content">One Content</t>
</Dropdown>
</DropdownGroup>
<DropdownGroup>
<Dropdown>
<button class="two">Two</button>
<t t-set-slot="content">Two Content</t>
</Dropdown>
</DropdownGroup>
</div>
`;
static components = { Dropdown, DropdownGroup };
static props = [];
}
await mountWithCleanup(Parent);
// Click on One
await click(".one");
await animationFrame();
expect(getDropdownMenu(".one")).toHaveCount(1);
// Hover on Two
await hover(".two");
await animationFrame();
expect(getDropdownMenu(".one")).toHaveCount(1);
expect(".one").toHaveClass("show");
expect(".two").not.toHaveClass("show");
});
test.tags("desktop");
test("DropdownGroup: when one is open, then non-sibling toggled, siblings must not be toggled on mouse-enter", async () => {
class Parent extends Component {
static components = { Dropdown, DropdownGroup };
static props = [];
static template = xml`
<div>
<DropdownGroup>
<Dropdown>
<button class="one">One</button>
<t t-set-slot="content">
One Content
</t>
</Dropdown>
</DropdownGroup>
<DropdownGroup>
<Dropdown>
<button class="two">Two</button>
<t t-set-slot="content">
Two Content
</t>
</Dropdown>
</DropdownGroup>
</div>
`;
}
await mountWithCleanup(Parent);
// Click on BAR1
await click(".two");
await animationFrame();
expect(DROPDOWN_MENU).toHaveCount(1);
// Click on FOO
await click(".one");
await animationFrame();
expect(DROPDOWN_MENU).toHaveCount(1);
// Hover on BAR1
await hover(".two");
await animationFrame();
expect(DROPDOWN_MENU).toHaveCount(1);
expect(".two-menu").toHaveCount(0);
});
test.tags("desktop");
test("DropdownGroup: toggler focused on mouseenter", async () => {
class Parent extends Component {
static components = { Dropdown, DropdownGroup };
static props = [];
static template = xml`
<DropdownGroup>
<Dropdown>
<button class="one">One</button>
<t t-set-slot="content">
One Content
</t>
</Dropdown>
<Dropdown>
<button class="two">Two</button>
<t t-set-slot="content">
Two Content
</t>
</Dropdown>
</DropdownGroup>
`;
}
await mountWithCleanup(Parent);
// Click on one
await click("button.one");
await animationFrame();
expect("button.one").toBeFocused();
expect(DROPDOWN_MENU).toHaveText("One Content");
// Hover on two
await hover("button.two");
await animationFrame();
expect("button.two").toBeFocused();
expect(DROPDOWN_MENU).toHaveText("Two Content");
});

View file

@ -0,0 +1,94 @@
import { expect, test } from "@odoo/hoot";
import { click } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
const DROPDOWN_TOGGLE = ".o-dropdown.dropdown-toggle";
const DROPDOWN_MENU = ".o-dropdown--menu.dropdown-menu";
const DROPDOWN_ITEM = ".o-dropdown-item.dropdown-item:not(.o-dropdown)";
test("can be rendered as <span/>", async () => {
class Parent extends Component {
static components = { DropdownItem };
static props = [];
static template = xml`<DropdownItem>coucou</DropdownItem>`;
}
await mountWithCleanup(Parent);
expect(".dropdown-item").toHaveClass(["o-dropdown-item", "o-navigable", "dropdown-item"]);
expect(".dropdown-item").toHaveAttribute("role", "menuitem");
});
test("(with href prop) can be rendered as <a/>", async () => {
class Parent extends Component {
static components = { DropdownItem };
static props = [];
static template = xml`<DropdownItem attrs="{ href: '#' }">coucou</DropdownItem>`;
}
await mountWithCleanup(Parent);
expect(DROPDOWN_ITEM).toHaveAttribute("href", "#");
});
test("prevents click default with href", async () => {
expect.assertions(4);
// A DropdownItem should preventDefault a click as it may take the shape
// of an <a/> tag with an [href] attribute and e.g. could change the url when clicked.
patchWithCleanup(DropdownItem.prototype, {
onClick(ev) {
expect(!ev.defaultPrevented).toBe(true);
super.onClick(...arguments);
const href = ev.target.getAttribute("href");
// defaultPrevented only if props.href is defined
expect(href !== null ? ev.defaultPrevented : !ev.defaultPrevented).toBe(true);
},
});
class Parent extends Component {
static components = { Dropdown, DropdownItem };
static props = [];
static template = xml`
<Dropdown>
<button>Coucou</button>
<t t-set-slot="content">
<DropdownItem class="'link'" attrs="{href: '#'}"/>
<DropdownItem class="'nolink'" />
</t>
</Dropdown>`;
}
await mountWithCleanup(Parent);
// The item containing the link class contains an href prop,
// which will turn it into <a href=> So it must be defaultPrevented
// The other one not contain any href props, it must not be defaultPrevented,
// so as not to prevent the background change flow for example
await click(DROPDOWN_TOGGLE);
await animationFrame();
await click(".link");
await click("button.dropdown-toggle");
await click(".nolink");
});
test("can be styled", async () => {
class Parent extends Component {
static components = { Dropdown, DropdownItem };
static props = [];
static template = xml`
<Dropdown menuClass="'test-menu'">
<button class="test-toggler">Coucou</button>
<t t-set-slot="content">
<DropdownItem class="'test-dropdown-item'">Item</DropdownItem>
</t>
</Dropdown>
`;
}
await mountWithCleanup(Parent);
expect(DROPDOWN_TOGGLE).toHaveClass("test-toggler");
await click(DROPDOWN_TOGGLE);
await animationFrame();
expect(DROPDOWN_MENU).toHaveClass("test-menu");
expect(DROPDOWN_ITEM).toHaveClass("test-dropdown-item");
});

View file

@ -0,0 +1,88 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { click, manuallyDispatchProgrammaticEvent, queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, markup, xml } from "@odoo/owl";
import { getService, mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { user } from "@web/core/user";
let effectParams;
beforeEach(async () => {
await mountWithCleanup(MainComponentsContainer);
effectParams = {
message: markup("<div>Congrats!</div>"),
};
});
test("effect service displays a rainbowman by default", async () => {
getService("effect").add();
await animationFrame();
expect(".o_reward").toHaveCount(1);
expect(".o_reward").toHaveText("Well Done!");
});
test("rainbowman effect with show_effect: false", async () => {
patchWithCleanup(user, { showEffect: false });
getService("effect").add();
await animationFrame();
expect(".o_reward").toHaveCount(0);
expect(".o_notification").toHaveCount(1);
});
test("rendering a rainbowman destroy after animation", async () => {
getService("effect").add(effectParams);
await animationFrame();
expect(".o_reward").toHaveCount(1);
expect(".o_reward_rainbow").toHaveCount(1);
expect(".o_reward_msg_content").toHaveInnerHTML("<div>Congrats!</div>");
await manuallyDispatchProgrammaticEvent(queryOne(".o_reward"), "animationend", {
animationName: "reward-fading-reverse",
});
await animationFrame();
expect(".o_reward").toHaveCount(0);
});
test("rendering a rainbowman destroy on click", async () => {
getService("effect").add(effectParams);
await animationFrame();
expect(".o_reward").toHaveCount(1);
expect(".o_reward_rainbow").toHaveCount(1);
await click(".o_reward");
await animationFrame();
expect(".o_reward").toHaveCount(0);
});
test("rendering a rainbowman with an escaped message", async () => {
getService("effect").add(effectParams);
await animationFrame();
expect(".o_reward").toHaveCount(1);
expect(".o_reward_rainbow").toHaveCount(1);
expect(".o_reward_msg_content").toHaveText("Congrats!");
});
test("rendering a rainbowman with a custom component", async () => {
expect.assertions(2);
const props = { foo: "bar" };
class Custom extends Component {
static template = xml`<div class="custom">foo is <t t-esc="props.foo"/></div>`;
static props = ["*"];
setup() {
expect(this.props).toEqual(props);
}
}
getService("effect").add({ Component: Custom, props });
await animationFrame();
expect(".o_reward_msg_content").toHaveInnerHTML(`<div class="custom">foo is bar</div>`);
});

View file

@ -0,0 +1,230 @@
import { browser } from "@web/core/browser/browser";
import { describe, test, expect } from "@odoo/hoot";
import { animationFrame, tick } from "@odoo/hoot-mock";
import {
mountWithCleanup,
patchWithCleanup,
mockService,
makeDialogMockEnv,
} from "@web/../tests/web_test_helpers";
import { click, freezeTime, queryAllTexts } from "@odoo/hoot-dom";
import {
ClientErrorDialog,
Error504Dialog,
ErrorDialog,
RedirectWarningDialog,
SessionExpiredDialog,
WarningDialog,
} from "@web/core/errors/error_dialogs";
describe.current.tags("desktop");
test("ErrorDialog with traceback", async () => {
freezeTime();
expect(".o_dialog").toHaveCount(0);
const env = await makeDialogMockEnv();
await mountWithCleanup(ErrorDialog, {
env,
props: {
message: "Something bad happened",
data: { debug: "Some strange unreadable stack" },
name: "ERROR_NAME",
traceback: "This is a traceback string",
close() {},
},
});
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Oops!");
expect("main button").toHaveText("See technical details");
expect(queryAllTexts("footer button")).toEqual(["Close"]);
expect("main p").toHaveText(
"Something went wrong... If you really are stuck, share the report with your friendly support service"
);
expect("div.o_error_detail").toHaveCount(0);
await click("main button");
await animationFrame();
expect(queryAllTexts("main .clearfix p")).toEqual([
"Odoo Error",
"Something bad happened",
"Occured on 2019-03-11 09:30:00 GMT",
]);
expect("main .clearfix code").toHaveText("ERROR_NAME");
expect("div.o_error_detail").toHaveCount(1);
expect("div.o_error_detail pre").toHaveText("This is a traceback string");
});
test("Client ErrorDialog with traceback", async () => {
freezeTime();
const env = await makeDialogMockEnv();
await mountWithCleanup(ClientErrorDialog, {
env,
props: {
message: "Something bad happened",
data: { debug: "Some strange unreadable stack" },
name: "ERROR_NAME",
traceback: "This is a traceback string",
close() {},
},
});
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Oops!");
expect("main button").toHaveText("See technical details");
expect(queryAllTexts("footer button")).toEqual(["Close"]);
expect("main p").toHaveText(
"Something went wrong... If you really are stuck, share the report with your friendly support service"
);
expect("div.o_error_detail").toHaveCount(0);
await click("main button");
await animationFrame();
expect(queryAllTexts("main .clearfix p")).toEqual([
"Odoo Client Error",
"Something bad happened",
"Occured on 2019-03-11 09:30:00 GMT",
]);
expect("main .clearfix code").toHaveText("ERROR_NAME");
expect("div.o_error_detail").toHaveCount(1);
expect("div.o_error_detail pre").toHaveText("This is a traceback string");
});
test("button clipboard copy error traceback", async () => {
freezeTime();
expect.assertions(1);
const error = new Error();
error.name = "ERROR_NAME";
error.message = "This is the message";
error.traceback = "This is a traceback";
patchWithCleanup(navigator.clipboard, {
writeText(value) {
expect(value).toBe(
`${error.name}\n\n${error.message}\n\nOccured on 2019-03-11 09:30:00 GMT\n\n${error.traceback}`
);
},
});
const env = await makeDialogMockEnv();
await mountWithCleanup(ErrorDialog, {
env,
props: {
message: error.message,
name: error.name,
traceback: error.traceback,
close() {},
},
});
await click("main button");
await animationFrame();
await click(".fa-clone");
await tick();
});
test("Display a tooltip on clicking copy button", async () => {
expect.assertions(1);
mockService("popover", () => ({
add(el, comp, params) {
expect(params).toEqual({ tooltip: "Copied" });
return () => {};
},
}));
const env = await makeDialogMockEnv();
await mountWithCleanup(ErrorDialog, {
env,
props: {
message: "This is the message",
name: "ERROR_NAME",
traceback: "This is a traceback",
close() {},
},
});
await click("main button");
await animationFrame();
await click(".fa-clone");
});
test("WarningDialog", async () => {
expect(".o_dialog").toHaveCount(0);
const env = await makeDialogMockEnv();
await mountWithCleanup(WarningDialog, {
env,
props: {
exceptionName: "odoo.exceptions.UserError",
message: "...",
data: { arguments: ["Some strange unreadable message"] },
close() {},
},
});
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Invalid Operation");
expect(".o_error_dialog").toHaveCount(1);
expect("main").toHaveText("Some strange unreadable message");
expect(".o_dialog footer button").toHaveText("Close");
});
test("RedirectWarningDialog", async () => {
mockService("action", {
doAction(actionId) {
expect.step(actionId);
},
});
expect(".o_dialog").toHaveCount(0);
const env = await makeDialogMockEnv();
await mountWithCleanup(RedirectWarningDialog, {
env,
props: {
data: {
arguments: [
"Some strange unreadable message",
"buy_action_id",
"Buy book on cryptography",
],
},
close() {
expect.step("dialog-closed");
},
},
});
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Odoo Warning");
expect("main").toHaveText("Some strange unreadable message");
expect(queryAllTexts("footer button")).toEqual(["Buy book on cryptography", "Close"]);
await click("footer button:nth-child(1)"); // click on "Buy book on cryptography"
await animationFrame();
expect.verifySteps(["buy_action_id", "dialog-closed"]);
await click("footer button:nth-child(2)"); // click on "Cancel"
await animationFrame();
expect.verifySteps(["dialog-closed"]);
});
test("Error504Dialog", async () => {
expect(".o_dialog").toHaveCount(0);
const env = await makeDialogMockEnv();
await mountWithCleanup(Error504Dialog, { env, props: { close() {} } });
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Request timeout");
expect("main p").toHaveText(
"The operation was interrupted. This usually means that the current operation is taking too much time."
);
expect(".o_dialog footer button").toHaveText("Close");
});
test("SessionExpiredDialog", async () => {
patchWithCleanup(browser.location, {
reload() {
expect.step("location reload");
},
});
expect(".o_dialog").toHaveCount(0);
const env = await makeDialogMockEnv();
await mountWithCleanup(SessionExpiredDialog, { env, props: { close() {} } });
expect(".o_dialog").toHaveCount(1);
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Odoo Session Expired");
expect("main p").toHaveText(
"Your Odoo session expired. The current page is about to be refreshed."
);
expect(".o_dialog footer button").toHaveText("Close");
await click(".o_dialog footer button");
await animationFrame();
expect.verifySteps(["location reload"]);
});

View file

@ -0,0 +1,529 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom";
import { Deferred, advanceTime, animationFrame } from "@odoo/hoot-mock";
import { Component, OwlError, onError, onWillStart, xml } from "@odoo/owl";
import {
makeMockEnv,
mockService,
mountWithCleanup,
onRpc,
patchWithCleanup,
serverState,
} from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import {
ClientErrorDialog,
RPCErrorDialog,
standardErrorDialogProps,
} from "@web/core/errors/error_dialogs";
import { UncaughtPromiseError } from "@web/core/errors/error_service";
import { ConnectionLostError, RPCError } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { omit } from "@web/core/utils/objects";
const errorDialogRegistry = registry.category("error_dialogs");
const errorHandlerRegistry = registry.category("error_handlers");
test("can handle rejected promise errors with a string as reason", async () => {
expect.assertions(2);
expect.errors(1);
await makeMockEnv();
errorHandlerRegistry.add(
"__test_handler__",
(env, err, originalError) => {
expect(originalError).toBe("-- something went wrong --");
},
{ sequence: 0 }
);
Promise.reject("-- something went wrong --");
await animationFrame();
expect.verifyErrors(["-- something went wrong --"]);
});
test("handle RPC_ERROR of type='server' and no associated dialog class", async () => {
expect.assertions(5);
expect.errors(1);
const error = new RPCError();
error.code = 701;
error.message = "Some strange error occured";
error.data = { debug: "somewhere" };
error.subType = "strange_error";
error.id = 12;
error.model = "some model";
mockService("dialog", {
add(dialogClass, props) {
expect(dialogClass).toBe(RPCErrorDialog);
expect(omit(props, "traceback", "serverHost")).toEqual({
name: "RPC_ERROR",
type: "server",
code: 701,
data: {
debug: "somewhere",
},
subType: "strange_error",
message: "Some strange error occured",
exceptionName: null,
id: 12,
model: "some model",
});
expect(props.traceback).toMatch(/RPC_ERROR/);
expect(props.traceback).toMatch(/Some strange error occured/);
},
});
await makeMockEnv();
Promise.reject(error);
await animationFrame();
expect.verifyErrors(["RPC_ERROR: Some strange error occured"]);
});
test("handle custom RPC_ERROR of type='server' and associated custom dialog class", async () => {
expect.assertions(5);
expect.errors(1);
class CustomDialog extends Component {
static template = xml`<RPCErrorDialog title="'Strange Error'"/>`;
static components = { RPCErrorDialog };
static props = { ...standardErrorDialogProps };
}
const error = new RPCError();
error.code = 701;
error.message = "Some strange error occured";
error.id = 12;
error.model = "some model";
const errorData = {
context: { exception_class: "strange_error" },
name: "strange_error",
};
error.data = errorData;
mockService("dialog", {
add(dialogClass, props) {
expect(dialogClass).toBe(CustomDialog);
expect(omit(props, "traceback", "serverHost")).toEqual({
name: "RPC_ERROR",
type: "server",
code: 701,
data: errorData,
subType: null,
message: "Some strange error occured",
exceptionName: null,
id: 12,
model: "some model",
});
expect(props.traceback).toMatch(/RPC_ERROR/);
expect(props.traceback).toMatch(/Some strange error occured/);
},
});
await makeMockEnv();
errorDialogRegistry.add("strange_error", CustomDialog);
Promise.reject(error);
await animationFrame();
expect.verifyErrors(["RPC_ERROR: Some strange error occured"]);
});
test("handle normal RPC_ERROR of type='server' and associated custom dialog class", async () => {
expect.assertions(5);
expect.errors(1);
class CustomDialog extends Component {
static template = xml`<RPCErrorDialog title="'Strange Error'"/>`;
static components = { RPCErrorDialog };
static props = ["*"];
}
class NormalDialog extends Component {
static template = xml`<RPCErrorDialog title="'Normal Error'"/>`;
static components = { RPCErrorDialog };
static props = ["*"];
}
const error = new RPCError();
error.code = 701;
error.message = "A normal error occured";
const errorData = {
context: { exception_class: "strange_error" },
};
error.exceptionName = "normal_error";
error.data = errorData;
error.id = 12;
error.model = "some model";
mockService("dialog", {
add(dialogClass, props) {
expect(dialogClass).toBe(NormalDialog);
expect(omit(props, "traceback", "serverHost")).toEqual({
name: "RPC_ERROR",
type: "server",
code: 701,
data: errorData,
subType: null,
message: "A normal error occured",
exceptionName: "normal_error",
id: 12,
model: "some model",
});
expect(props.traceback).toMatch(/RPC_ERROR/);
expect(props.traceback).toMatch(/A normal error occured/);
},
});
await makeMockEnv();
errorDialogRegistry.add("strange_error", CustomDialog);
errorDialogRegistry.add("normal_error", NormalDialog);
Promise.reject(error);
await animationFrame();
expect.verifyErrors(["RPC_ERROR: A normal error occured"]);
});
test("handle CONNECTION_LOST_ERROR", async () => {
expect.errors(1);
mockService("notification", {
add(message) {
expect.step(`create (${message})`);
return () => {
expect.step(`close`);
};
},
});
const values = [false, true]; // simulate the 'back online status' after 2 'version_info' calls
onRpc("/web/webclient/version_info", async () => {
expect.step("version_info");
const online = values.shift();
if (online) {
return true;
} else {
return Promise.reject();
}
});
await makeMockEnv();
const error = new ConnectionLostError("/fake_url");
Promise.reject(error);
await animationFrame();
patchWithCleanup(Math, {
random: () => 0,
});
// wait for timeouts
await advanceTime(2000);
await advanceTime(3500);
expect.verifySteps([
"create (Connection lost. Trying to reconnect...)",
"version_info",
"version_info",
"close",
"create (Connection restored. You are back online.)",
]);
expect.verifyErrors([
`Error: Connection to "/fake_url" couldn't be established or was interrupted`,
]);
});
test("will let handlers from the registry handle errors first", async () => {
expect.assertions(4);
expect.errors(1);
const testEnv = await makeMockEnv();
testEnv.someValue = 14;
errorHandlerRegistry.add("__test_handler__", (env, err, originalError) => {
expect(originalError).toBe(error);
expect(env.someValue).toBe(14);
expect.step("in handler");
return true;
});
const error = new Error();
error.name = "boom";
Promise.reject(error);
await animationFrame();
expect.verifyErrors(["boom"]);
expect.verifySteps(["in handler"]);
});
test("originalError is the root cause of the error chain", async () => {
expect.assertions(10);
expect.errors(2);
await makeMockEnv();
const error = new Error();
error.name = "boom";
errorHandlerRegistry.add("__test_handler__", (env, err, originalError) => {
expect(err).toBeInstanceOf(UncaughtPromiseError); // Wrapped by error service
expect(err.cause).toBeInstanceOf(OwlError); // Wrapped by owl
expect(err.cause.cause).toBe(originalError); // original error
expect.step("in handler");
return true;
});
class ErrHandler extends Component {
static template = xml`<t t-component="props.comp"/>`;
static props = ["*"];
setup() {
onError(async (err) => {
Promise.reject(err);
await animationFrame();
prom.resolve();
});
}
}
class ThrowInSetup extends Component {
static template = xml``;
static props = ["*"];
setup() {
throw error;
}
}
let prom = new Deferred();
mountWithCleanup(ErrHandler, { props: { comp: ThrowInSetup } });
await prom;
expect.verifyErrors([
`Error: An error occured in the owl lifecycle (see this Error's "cause" property)`,
]);
expect.verifySteps(["in handler"]);
class ThrowInWillStart extends Component {
static template = xml``;
static props = ["*"];
setup() {
onWillStart(() => {
throw error;
});
}
}
prom = new Deferred();
mountWithCleanup(ErrHandler, { props: { comp: ThrowInWillStart } });
await prom;
expect.verifyErrors([`Error: The following error occurred in onWillStart: ""`]);
expect.verifySteps(["in handler"]);
});
test("handle uncaught promise errors", async () => {
expect.assertions(5);
expect.errors(1);
class TestError extends Error {}
const error = new TestError();
error.message = "This is an error test";
error.name = "TestError";
mockService("dialog", {
add(dialogClass, props) {
expect(dialogClass).toBe(ClientErrorDialog);
expect(omit(props, "traceback", "serverHost")).toEqual({
name: "UncaughtPromiseError > TestError",
message: "Uncaught Promise > This is an error test",
});
expect(props.traceback).toMatch(/TestError/);
expect(props.traceback).toMatch(/This is an error test/);
},
});
await makeMockEnv();
Promise.reject(error);
await animationFrame();
expect.verifyErrors(["TestError: This is an error test"]);
});
test("handle uncaught client errors", async () => {
expect.assertions(4);
expect.errors(1);
class TestError extends Error {}
const error = new TestError();
error.message = "This is an error test";
error.name = "TestError";
mockService("dialog", {
add(dialogClass, props) {
expect(dialogClass).toBe(ClientErrorDialog);
expect(props.name).toBe("UncaughtClientError > TestError");
expect(props.message).toBe("Uncaught Javascript Error > This is an error test");
},
});
await makeMockEnv();
setTimeout(() => {
throw error;
});
await animationFrame();
expect.verifyErrors(["TestError: This is an error test"]);
});
test("don't show dialog for errors in third-party scripts", async () => {
expect.errors(1);
class TestError extends Error {}
const error = new TestError();
error.name = "Script error.";
mockService("dialog", {
add(_dialogClass, props) {
throw new Error("should not pass here");
},
});
await makeMockEnv();
// Error events from errors in third-party scripts have no colno, no lineno and no filename
// because of CORS.
await manuallyDispatchProgrammaticEvent(window, "error", { error });
await animationFrame();
expect.verifyErrors(["Script error."]);
});
test("show dialog for errors in third-party scripts in debug mode", async () => {
expect.errors(1);
class TestError extends Error {}
const error = new TestError();
error.name = "Script error.";
serverState.debug = "1";
mockService("dialog", {
add(_dialogClass, props) {
expect.step("Dialog: " + props.message);
return () => {};
},
});
await makeMockEnv();
// Error events from errors in third-party scripts have no colno, no lineno and no filename
// because of CORS.
await manuallyDispatchProgrammaticEvent(window, "error", { error });
await animationFrame();
expect.verifyErrors(["Script error."]);
expect.verifySteps(["Dialog: Third-Party Script Error"]);
});
test("lazy loaded handlers", async () => {
expect.assertions(3);
expect.errors(2);
await makeMockEnv();
Promise.reject(new Error("error"));
await animationFrame();
expect.verifyErrors(["Error: error"]);
errorHandlerRegistry.add("__test_handler__", () => {
expect.step("in handler");
return true;
});
Promise.reject(new Error("error"));
await animationFrame();
expect.verifyErrors(["Error: error"]);
expect.verifySteps(["in handler"]);
});
let unhandledRejectionCb;
let errorCb;
describe("Error Service Logs", () => {
beforeEach(() => {
patchWithCleanup(browser, {
addEventListener: (type, cb) => {
if (type === "unhandledrejection") {
unhandledRejectionCb = cb;
} else if (type === "error") {
errorCb = cb;
}
},
});
});
test("logs the traceback of the full error chain for unhandledrejection", async () => {
expect.assertions(2);
const regexParts = [
/^.*This is a wrapper error/,
/Caused by:.*This is a second wrapper error/,
/Caused by:.*This is the original error/,
];
const errorRegex = new RegExp(regexParts.map((re) => re.source).join(/[\s\S]*/.source));
patchWithCleanup(console, {
error(errorMessage) {
expect(errorMessage).toMatch(errorRegex);
},
});
const error = new Error("This is a wrapper error");
error.cause = new Error("This is a second wrapper error");
error.cause.cause = new Error("This is the original error");
await makeMockEnv();
const errorEvent = new PromiseRejectionEvent("unhandledrejection", {
reason: error,
promise: null,
cancelable: true,
});
await unhandledRejectionCb(errorEvent);
expect(errorEvent.defaultPrevented).toBe(true);
});
test("logs the traceback of the full error chain for uncaughterror", async () => {
expect.assertions(2);
const regexParts = [
/^.*This is a wrapper error/,
/Caused by:.*This is a second wrapper error/,
/Caused by:.*This is the original error/,
];
const errorRegex = new RegExp(regexParts.map((re) => re.source).join(/[\s\S]*/.source));
patchWithCleanup(console, {
error(errorMessage) {
expect(errorMessage).toMatch(errorRegex);
},
});
const error = new Error("This is a wrapper error");
error.cause = new Error("This is a second wrapper error");
error.cause.cause = new Error("This is the original error");
await makeMockEnv();
const errorEvent = new Event("error", {
promise: null,
cancelable: true,
});
errorEvent.error = error;
errorEvent.filename = "dummy_file.js"; // needed to not be treated as a CORS error
await errorCb(errorEvent);
expect(errorEvent.defaultPrevented).toBe(true);
});
test("error in handlers while handling an error", async () => {
// Scenario: an error occurs at the early stage of the "boot" sequence, error handlers
// that are supposed to spawn dialogs are not ready then and will crash.
// We assert that *exactly one* error message is logged, that contains the original error's traceback
// and an indication that a handler has crashed just for not loosing information.
// The crash of the error handler should merely be seen as a consequence of the early stage at which the error occurs.
errorHandlerRegistry.add(
"__test_handler__",
(env, err, originalError) => {
throw new Error("Boom in handler");
},
{ sequence: 0 }
);
// We want to assert that the error_service code does the preventDefault.
patchWithCleanup(console, {
error(errorMessage) {
expect(errorMessage).toMatch(
new RegExp(
`^@web/core/error_service: handler "__test_handler__" failed with "Error: Boom in handler" while trying to handle:\nError: Genuine Business Boom.*`
)
);
expect.step("error logged");
},
});
await makeMockEnv();
let errorEvent = new Event("error", {
promise: null,
cancelable: true,
});
errorEvent.error = new Error("Genuine Business Boom");
errorEvent.error.annotatedTraceback = "annotated";
errorEvent.filename = "dummy_file.js"; // needed to not be treated as a CORS error
await errorCb(errorEvent);
expect(errorEvent.defaultPrevented).toBe(true);
expect.verifySteps(["error logged"]);
errorEvent = new PromiseRejectionEvent("unhandledrejection", {
promise: null,
cancelable: true,
reason: new Error("Genuine Business Boom"),
});
await unhandledRejectionCb(errorEvent);
expect(errorEvent.defaultPrevented).toBe(true);
expect.verifySteps(["error logged"]);
});
});

View file

@ -0,0 +1,449 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { click, edit, queryAllTexts, queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
Country,
Partner,
Player,
Product,
Stage,
Team,
addNewRule,
clearNotSupported,
clickOnButtonAddBranch,
clickOnButtonAddNewRule,
clickOnButtonDeleteNode,
editValue,
getOperatorOptions,
getTreeEditorContent,
getValueOptions,
isNotSupportedPath,
openModelFieldSelectorPopover,
selectOperator,
SELECTORS as treeEditorSELECTORS,
} from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
import {
contains,
defineModels,
fields,
mountWithCleanup,
serverState,
} from "@web/../tests/web_test_helpers";
import { ExpressionEditor } from "@web/core/expression_editor/expression_editor";
import { Component, xml } from "@odoo/owl";
import { pick } from "@web/core/utils/objects";
const SELECTORS = {
...treeEditorSELECTORS,
debugArea: ".o_expression_editor_debug_container textarea",
};
/**
* @param {string} value
*/
async function editExpression(value) {
await click(SELECTORS.complexConditionInput);
await edit(value);
await animationFrame();
}
/**
* @param {string} value
*/
async function selectConnector(value) {
await contains(`${SELECTORS.connector} .dropdown-toggle`).click();
await contains(`.dropdown-menu .dropdown-item:contains(${value})`).click();
}
async function makeExpressionEditor(params = {}) {
const fieldFilters = params.fieldFilters;
delete params.fieldFilters;
const props = { ...params };
class Parent extends Component {
static components = { ExpressionEditor };
static template = xml`<ExpressionEditor t-props="expressionEditorProps"/>`;
static props = ["*"];
setup() {
this.expressionEditorProps = {
expression: "1",
...props,
resModel: "partner",
update: (expression) => {
if (props.update) {
props.update(expression);
}
this.expressionEditorProps.expression = expression;
this.render();
},
};
this.expressionEditorProps.fields = fieldFilters
? pick(Partner._fields, ...fieldFilters)
: Partner._fields;
}
async set(expression) {
this.expressionEditorProps.expression = expression;
this.render();
await animationFrame();
}
}
return mountWithCleanup(Parent, { props });
}
defineModels([Partner, Product, Team, Player, Country, Stage]);
beforeEach(() => {
serverState.debug = "1";
});
test("rendering of truthy values", async () => {
const toTests = [`True`, `true`, `1`, `-1`, `"a"`];
const parent = await makeExpressionEditor();
for (const expr of toTests) {
await parent.set(expr);
expect(getTreeEditorContent()).toEqual([{ level: 0, value: "all" }]);
}
});
test("rendering of falsy values", async () => {
const toTests = [`False`, `false`, `0`, `""`];
const parent = await makeExpressionEditor();
for (const expr of toTests) {
await parent.set(expr);
expect(getTreeEditorContent()).toEqual([
{ value: "all", level: 0 },
{ value: ["0", "=", "1"], level: 1 },
]);
}
});
test("rendering of 'expr'", async () => {
serverState.debug = "";
await makeExpressionEditor({ expression: "expr" });
expect(getTreeEditorContent()).toEqual([
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
expect(queryOne(SELECTORS.complexConditionInput).readOnly).toBe(true);
});
test("rendering of 'expr' in dev mode", async () => {
await makeExpressionEditor({ expression: "expr" });
expect(getTreeEditorContent()).toEqual([
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
expect(queryOne(SELECTORS.complexConditionInput).readOnly).toBe(false);
});
test("edit a complex condition in dev mode", async () => {
await makeExpressionEditor({ expression: "expr" });
expect(SELECTORS.condition).toHaveCount(0);
expect(getTreeEditorContent()).toEqual([
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
await editExpression("uid");
expect(getTreeEditorContent()).toEqual([
{ value: "all", level: 0 },
{ value: "uid", level: 1 },
]);
});
test("delete a complex condition", async () => {
await makeExpressionEditor({ expression: "expr" });
expect(getTreeEditorContent()).toEqual([
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
await clickOnButtonDeleteNode();
expect(getTreeEditorContent()).toEqual([{ value: "all", level: 0 }]);
});
test("copy a complex condition", async () => {
await makeExpressionEditor({ expression: "expr" });
expect(SELECTORS.condition).toHaveCount(0);
expect(getTreeEditorContent()).toEqual([
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
await clickOnButtonAddNewRule();
expect(getTreeEditorContent()).toEqual([
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
{ value: "expr", level: 1 },
]);
});
test("change path, operator and value", async () => {
serverState.debug = "";
await makeExpressionEditor({ expression: `bar != "blabla"` });
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "all" },
{ level: 1, value: ["Bar", "is not", "blabla"] },
]);
const tree = getTreeEditorContent({ node: true });
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector_popover_item_name:eq(5)").click();
await selectOperator("not in", 0, tree[1].node);
await editValue(["Doku", "Lukaku", "KDB"]);
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "all" },
{ level: 1, value: ["Foo", "is not in", "Doku,Lukaku,KDB"] },
]);
});
test("create a new branch from a complex condition control panel", async () => {
await makeExpressionEditor({ expression: "expr" });
expect(getTreeEditorContent()).toEqual([
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
await clickOnButtonAddBranch();
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "all" },
{ level: 1, value: "expr" },
{ level: 1, value: "any" },
{ level: 2, value: ["Id", "=", "1"] },
{ level: 2, value: ["Id", "=", "1"] },
]);
});
test("rendering of a valid fieldName in fields", async () => {
const parent = await makeExpressionEditor({ fieldFilters: ["foo"] });
const toTests = [
{ expr: `foo`, condition: ["Foo", "is set"] },
{ expr: `foo == "a"`, condition: ["Foo", "=", "a"] },
{ expr: `foo != "a"`, condition: ["Foo", "!=", "a"] },
// { expr: `foo is "a"`, complexCondition: `foo is "a"` },
// { expr: `foo is not "a"`, complexCondition: `foo is not "a"` },
{ expr: `not foo`, condition: ["Foo", "is not set"] },
{ expr: `foo + "a"`, complexCondition: `foo + "a"` },
];
for (const { expr, condition, complexCondition } of toTests) {
await parent.set(expr);
const tree = getTreeEditorContent();
if (condition) {
expect(tree).toEqual([
{ value: "all", level: 0 },
{ value: condition, level: 1 },
]);
} else if (complexCondition) {
expect(tree).toEqual([
{ value: "all", level: 0 },
{ value: complexCondition, level: 1 },
]);
}
}
});
test("rendering of simple conditions", async () => {
Partner._fields.bar = fields.Char();
Partner._records = [];
const parent = await makeExpressionEditor({ fieldFilters: ["foo", "bar"] });
const toTests = [
{ expr: `bar == "a"`, condition: ["Bar", "=", "a"] },
{ expr: `foo == expr`, condition: ["Foo", "=", "expr"] },
{ expr: `"a" == foo`, condition: ["Foo", "=", "a"] },
{ expr: `expr == foo`, condition: ["Foo", "=", "expr"] },
{ expr: `foo == bar`, complexCondition: `foo == bar` },
{ expr: `"a" == "b"`, complexCondition: `"a" == "b"` },
{ expr: `expr1 == expr2`, complexCondition: `expr1 == expr2` },
{ expr: `foo < "a"`, condition: ["Foo", "<", "a"] },
{ expr: `foo < expr`, condition: ["Foo", "<", "expr"] },
{ expr: `"a" < foo`, condition: ["Foo", ">", "a"] },
{ expr: `expr < foo`, condition: ["Foo", ">", "expr"] },
{ expr: `foo < bar`, complexCondition: `foo < bar` },
{ expr: `"a" < "b"`, complexCondition: `"a" < "b"` },
{ expr: `expr1 < expr2`, complexCondition: `expr1 < expr2` },
{ expr: `foo in ["a"]`, condition: ["Foo", "is in", "a"] },
{ expr: `foo in [expr]`, condition: ["Foo", "is in", "expr"] },
{ expr: `"a" in foo`, complexCondition: `"a" in foo` },
{ expr: `expr in foo`, complexCondition: `expr in foo` },
{ expr: `foo in bar`, complexCondition: `foo in bar` },
{ expr: `"a" in "b"`, complexCondition: `"a" in "b"` },
{ expr: `expr1 in expr2`, complexCondition: `expr1 in expr2` },
];
for (const { expr, condition, complexCondition } of toTests) {
await parent.set(expr);
const tree = getTreeEditorContent();
if (condition) {
expect(tree).toEqual([
{ value: "all", level: 0 },
{ value: condition, level: 1 },
]);
} else if (complexCondition) {
expect(tree).toEqual([
{ value: "all", level: 0 },
{ value: complexCondition, level: 1 },
]);
}
}
});
test("rendering of connectors", async () => {
await makeExpressionEditor({ expression: `expr and foo == "abc" or not bar` });
expect(queryAllTexts(`${SELECTORS.connector} .dropdown-toggle`)).toEqual(["any", "all"]);
const tree = getTreeEditorContent();
expect(tree).toEqual([
{ level: 0, value: "any" },
{ level: 1, value: "all" },
{ level: 2, value: "expr" },
{ level: 2, value: ["Foo", "=", "abc"] },
{ level: 1, value: ["Bar", "is", "not set"] },
]);
});
test("rendering of connectors (2)", async () => {
await makeExpressionEditor({
expression: `not (expr or foo == "abc")`,
update(expression) {
expect.step(expression);
},
});
expect(`${SELECTORS.connector} .dropdown-toggle:only`).toHaveText("none");
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "none" },
{ level: 1, value: "expr" },
{ level: 1, value: ["Foo", "=", "abc"] },
]);
expect.verifySteps([]);
expect(queryOne(SELECTORS.debugArea)).toHaveValue(`not (expr or foo == "abc")`);
await selectConnector("all");
expect(`${SELECTORS.connector} .dropdown-toggle:only`).toHaveText("all");
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "all" },
{ level: 1, value: "expr" },
{ level: 1, value: ["Foo", "=", "abc"] },
]);
expect.verifySteps([`expr and foo == "abc"`]);
expect(queryOne(SELECTORS.debugArea)).toHaveValue(`expr and foo == "abc"`);
});
test("rendering of if else", async () => {
await makeExpressionEditor({ expression: `True if False else False` });
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "any" },
{ level: 1, value: "all" },
{ level: 2, value: ["0", "=", "1"] },
{ level: 2, value: ["1", "=", "1"] },
{ level: 1, value: "all" },
{ level: 2, value: ["1", "=", "1"] },
{ level: 2, value: ["0", "=", "1"] },
]);
});
test("check condition by default when creating a new rule", async () => {
serverState.debug = "";
Partner._fields.country_id = fields.Char({ string: "Country ID" });
await makeExpressionEditor({ expression: "expr" });
await contains("a[role='button']").click();
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "all" },
{ level: 1, value: "expr" },
{ level: 1, value: ["Country ID", "=", ""] },
]);
});
test("allow selection of boolean field", async () => {
await makeExpressionEditor({ expression: "id" });
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "all" },
{ level: 1, value: ["Id", "is set"] },
]);
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector_popover_item_name").click();
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "all" },
{ level: 1, value: ["Bar", "is", "set"] },
]);
});
test("render false and true leaves", async () => {
await makeExpressionEditor({ expression: `False and True` });
expect(getOperatorOptions()).toEqual(["="]);
expect(getValueOptions()).toEqual(["1"]);
expect(getOperatorOptions(-1)).toEqual(["="]);
expect(getValueOptions(-1)).toEqual(["1"]);
});
test("no field of type properties in model field selector", async () => {
serverState.debug = "";
Partner._fields.properties = fields.Properties({
string: "Properties",
definition_record: "product_id",
definition_record_field: "definitions",
});
Product._fields.definitions = fields.PropertiesDefinition();
await makeExpressionEditor({
expression: `properties`,
fieldFilters: ["foo", "bar", "properties"],
update(expression) {
expect.step(expression);
},
});
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "all" },
{ level: 1, value: ["Properties", "is set"] },
]);
expect(isNotSupportedPath()).toBe(true);
await clearNotSupported();
expect.verifySteps([`foo == ""`]);
await openModelFieldSelectorPopover();
expect(queryAllTexts(".o_model_field_selector_popover_item_name")).toEqual(["Bar", "Foo"]);
});
test("no special fields in fields", async () => {
serverState.debug = "";
await makeExpressionEditor({
expression: `bar`,
fieldFilters: ["foo", "bar", "properties"],
update(expression) {
expect.step(expression);
},
});
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "all" },
{ level: 1, value: ["Bar", "is not", "not set"] },
]);
await addNewRule();
expect(getTreeEditorContent()).toEqual([
{ level: 0, value: "all" },
{ level: 1, value: ["Bar", "is not", "not set"] },
{ level: 1, value: ["Foo", "=", ""] },
]);
expect.verifySteps([`bar and foo == ""`]);
});
test("between operator", async () => {
await makeExpressionEditor({
expression: `id == 1`,
update(expression) {
expect.step(expression);
},
});
expect(getOperatorOptions()).toEqual([
"=",
"!=",
">",
">=",
"<",
"<=",
"is between",
"is set",
"is not set",
]);
expect.verifySteps([]);
await selectOperator("between");
expect.verifySteps([`id >= 1 and id <= 1`]);
});

View file

@ -0,0 +1,87 @@
import { expect, test, describe } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import {
contains,
defineModels,
mountWithCleanup,
makeDialogMockEnv,
mockService,
} from "@web/../tests/web_test_helpers";
import {
Country,
Partner,
Player,
Product,
Stage,
Team,
getTreeEditorContent,
} from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
import { ExpressionEditorDialog } from "@web/core/expression_editor_dialog/expression_editor_dialog";
describe.current.tags("desktop");
async function makeExpressionEditorDialog(params = {}) {
const props = { ...params };
class Parent extends Component {
static components = { ExpressionEditorDialog };
static template = xml`<ExpressionEditorDialog t-props="expressionEditorProps"/>`;
static props = ["*"];
setup() {
this.expressionEditorProps = {
expression: "1",
close: () => {},
onConfirm: () => {},
...props,
resModel: "partner",
};
this.expressionEditorProps.fields = Partner._fields;
}
async set(expression) {
this.expressionEditorProps.expression = expression;
this.render();
await animationFrame();
}
}
const env = await makeDialogMockEnv();
return mountWithCleanup(Parent, { env, props });
}
defineModels([Partner, Product, Team, Player, Country, Stage]);
test("expr well sent, onConfirm and onClose", async () => {
const expression = `foo == 'batestr' and bar == True`;
await makeExpressionEditorDialog({
expression,
close: () => {
expect.step("close");
},
onConfirm: (result) => {
expect.step(result);
},
});
expect(".o_technical_modal").toHaveCount(1);
await contains(".o_dialog footer button").click();
expect.verifySteps([expression, "close"]);
});
test("expr well sent but wrong, so notification when onConfirm", async () => {
const expression = `foo == 'bar' and bar = True`;
mockService("notification", {
add(message, options) {
expect(message).toBe("Expression is invalid. Please correct it");
expect(options).toEqual({ type: "danger" });
expect.step("notification");
},
});
await makeExpressionEditorDialog({
expression,
});
expect(".o_technical_modal").toHaveCount(1);
await contains(".modal-footer button").click();
await contains(".modal-body button").click();
expect(getTreeEditorContent()).toEqual([{ level: 0, value: "all" }]);
expect.verifySteps(["notification"]);
});

View file

@ -0,0 +1,298 @@
import { expect, test } from "@odoo/hoot";
import {
defineModels,
fields,
getService,
makeMockEnv,
MockServer,
models,
mountWithCleanup,
onRpc,
} from "@web/../tests/web_test_helpers";
import { Deferred, animationFrame } from "@odoo/hoot-mock";
import { Component, useState, xml } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
function getModelInfo(resModel) {
return {
resModel: resModel._name,
fieldDefs: JSON.parse(JSON.stringify(resModel._fields)),
};
}
function getDefinitions() {
const fieldDefs = {};
for (const record of MockServer.env["species"]) {
for (const definition of record.definitions) {
fieldDefs[definition.name] = {
is_property: true,
searchable: true,
record_name: record.display_name,
record_id: record.id,
...definition,
};
}
}
return { resModel: "*", fieldDefs };
}
class Tortoise extends models.Model {
name = fields.Char();
age = fields.Integer();
location_id = fields.Many2one({ string: "Location", relation: "location" });
species = fields.Many2one({ relation: "species" });
property_field = fields.Properties({
string: "Properties",
definition_record: "species",
definition_record_field: "definitions",
});
}
class Location extends models.Model {
name = fields.Char();
tortoise_ids = fields.One2many({ string: "Turtles", relation: "tortoise" });
}
class Species extends models.Model {
name = fields.Char();
definitions = fields.PropertiesDefinition();
_records = [
{
id: 1,
display_name: "Galápagos tortoise",
definitions: [
{
name: "galapagos_lifespans",
string: "Lifespans",
type: "integer",
},
{
name: "location_ids",
string: "Locations",
type: "many2many",
relation: "location",
},
],
},
{
id: 2,
display_name: "Aldabra giant tortoise",
definitions: [
{ name: "aldabra_lifespans", string: "Lifespans", type: "integer" },
{ name: "color", string: "Color", type: "char" },
],
},
];
}
defineModels([Tortoise, Location, Species]);
test("loadPath", async () => {
await makeMockEnv();
const toTest = [
{
resModel: "tortoise",
path: "*",
expectedResult: {
names: ["*"],
modelsInfo: [getModelInfo(Tortoise)],
},
},
{
resModel: "tortoise",
path: "*.a",
expectedResult: {
isInvalid: "path",
names: ["*", "a"],
modelsInfo: [getModelInfo(Tortoise)],
},
},
{
resModel: "tortoise",
path: "location_id.*",
expectedResult: {
names: ["location_id", "*"],
modelsInfo: [getModelInfo(Tortoise), getModelInfo(Location)],
},
},
{
resModel: "tortoise",
path: "age",
expectedResult: {
names: ["age"],
modelsInfo: [getModelInfo(Tortoise)],
},
},
{
resModel: "tortoise",
path: "location_id",
expectedResult: {
names: ["location_id"],
modelsInfo: [getModelInfo(Tortoise)],
},
},
{
resModel: "tortoise",
path: "location_id.tortoise_ids",
expectedResult: {
names: ["location_id", "tortoise_ids"],
modelsInfo: [getModelInfo(Tortoise), getModelInfo(Location)],
},
},
{
resModel: "tortoise",
path: "location_id.tortoise_ids.age",
expectedResult: {
names: ["location_id", "tortoise_ids", "age"],
modelsInfo: [
getModelInfo(Tortoise),
getModelInfo(Location),
getModelInfo(Tortoise),
],
},
},
{
resModel: "tortoise",
path: "location_id.tortoise_ids.age",
expectedResult: {
names: ["location_id", "tortoise_ids", "age"],
modelsInfo: [
getModelInfo(Tortoise),
getModelInfo(Location),
getModelInfo(Tortoise),
],
},
},
{
resModel: "tortoise",
path: "property_field",
expectedResult: {
names: ["property_field"],
modelsInfo: [getModelInfo(Tortoise)],
},
},
{
resModel: "tortoise",
path: "property_field.galapagos_lifespans",
expectedResult: {
names: ["property_field", "galapagos_lifespans"],
modelsInfo: [getModelInfo(Tortoise), getDefinitions()],
},
},
{
resModel: "tortoise",
path: "property_field.location_ids.tortoise_ids",
expectedResult: {
isInvalid: "path",
names: ["property_field", "location_ids", "tortoise_ids"],
modelsInfo: [getModelInfo(Tortoise), getDefinitions()],
},
},
];
for (const { resModel, path, expectedResult } of toTest) {
const result = await getService("field").loadPath(resModel, path);
expect(result).toEqual(expectedResult);
}
const errorToTest = [
{ resModel: "notAModel" },
{ resModel: "tortoise", path: {} },
{ resModel: "tortoise", path: "" },
];
for (const { resModel, path } of errorToTest) {
try {
await getService("field").loadPath(resModel, path);
} catch {
expect.step("error");
}
}
expect.verifySteps(errorToTest.map(() => "error"));
});
test("store loadFields calls in cache in success", async () => {
onRpc("fields_get", () => {
expect.step("fields_get");
});
await makeMockEnv();
await getService("field").loadFields("tortoise");
await getService("field").loadFields("tortoise");
expect.verifySteps(["fields_get"]);
});
test("does not store loadFields calls in cache when failed", async () => {
onRpc("fields_get", () => {
expect.step("fields_get");
throw "my little error";
});
await makeMockEnv();
await expect(getService("field").loadFields("take.five")).rejects.toThrow(/my little error/);
await expect(getService("field").loadFields("take.five")).rejects.toThrow(/my little error/);
expect.verifySteps(["fields_get", "fields_get"]);
});
test("async method loadFields is protected", async () => {
let callFieldService;
class Child extends Component {
static template = xml`
<div class="o_child_component" />
`;
static props = ["*"];
setup() {
this.fieldService = useService("field");
callFieldService = async () => {
expect.step("loadFields called");
await this.fieldService.loadFields("tortoise");
expect.step("loadFields result get");
};
}
}
class Parent extends Component {
static components = { Child };
static template = xml`
<t t-if="state.displayChild">
<Child />
</t>
`;
static props = ["*"];
setup() {
this.state = useState({ displayChild: true });
}
}
const def = new Deferred();
onRpc(async () => {
await def;
});
const parent = await mountWithCleanup(Parent);
expect(".o_child_component").toHaveCount(1);
callFieldService();
expect.verifySteps(["loadFields called"]);
parent.state.displayChild = false;
await animationFrame();
def.resolve();
await animationFrame();
expect.verifySteps([]);
try {
await callFieldService();
} catch (e) {
expect.step(e.message);
}
expect.verifySteps(["loadFields called", "Component is destroyed"]);
});

View file

@ -0,0 +1,195 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import {
contains,
mockService,
mountWithCleanup,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { setInputFiles } from "@odoo/hoot-dom";
import { Deferred, animationFrame } from "@odoo/hoot-mock";
import { FileInput } from "@web/core/file_input/file_input";
import { session } from "@web/session";
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
async function createFileInput({ mockPost, mockAdd, props }) {
mockService("notification", {
add: mockAdd || (() => {}),
});
mockService("http", {
post: mockPost || (() => {}),
});
await mountWithCleanup(FileInput, { props });
}
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
beforeEach(() => {
patchWithCleanup(odoo, { csrf_token: "dummy" });
});
test("Upload a file: default props", async () => {
expect.assertions(5);
await createFileInput({
mockPost: (route, params) => {
expect(params).toEqual({
csrf_token: "dummy",
ufile: [],
});
expect.step(route);
return "[]";
},
props: {},
});
expect(".o_file_input").toHaveText("Choose File", {
message: "File input total text should match its given inner element's text",
});
expect(".o_file_input input").toHaveAttribute("accept", "*", {
message: "Input should accept all files by default",
});
await contains(".o_file_input input", { visible: false }).click();
await setInputFiles([]);
expect(".o_file_input input").not.toHaveAttribute("multiple", null, {
message: "'multiple' attribute should not be set",
});
expect.verifySteps(["/web/binary/upload_attachment"]);
});
test("Upload a file: custom attachment", async () => {
expect.assertions(5);
await createFileInput({
props: {
acceptedFileExtensions: ".png",
multiUpload: true,
resId: 5,
resModel: "res.model",
route: "/web/binary/upload",
onUpload(files) {
expect(files).toHaveLength(0, {
message: "'files' property should be an empty array",
});
},
},
mockPost: (route, params) => {
expect(params).toEqual({
id: 5,
model: "res.model",
csrf_token: "dummy",
ufile: [],
});
expect.step(route);
return "[]";
},
});
expect(".o_file_input input").toHaveAttribute("accept", ".png", {
message: "Input should now only accept pngs",
});
await contains(".o_file_input input", { visible: false }).click();
await setInputFiles([]);
expect(".o_file_input input").toHaveAttribute("multiple", null, {
message: "'multiple' attribute should be set",
});
expect.verifySteps(["/web/binary/upload"]);
});
test("Hidden file input", async () => {
await createFileInput({
props: { hidden: true },
});
expect(".o_file_input").not.toBeVisible();
});
test("uploading the same file twice triggers the onChange twice", async () => {
await createFileInput({
props: {
onUpload(files) {
expect.step(files[0].name);
},
},
mockPost: (_, params) => {
return JSON.stringify([{ name: params.ufile[0].name }]);
},
});
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
await contains(".o_file_input input", { visible: false }).click();
await setInputFiles([file]);
await animationFrame();
expect.verifySteps(["fake_file.txt"]);
await contains(".o_file_input input", { visible: false }).click();
await setInputFiles([file]);
await animationFrame();
expect.verifySteps(["fake_file.txt"]);
});
test("uploading a file that is too heavy will send a notification", async () => {
patchWithCleanup(session, { max_file_upload_size: 2 });
await createFileInput({
props: {
onUpload(files) {
// This code should be unreachable in this case
expect.step(files[0].name);
},
},
mockPost: (_, params) => {
return JSON.stringify([{ name: params.ufile[0].name }]);
},
mockAdd: (message) => {
expect.step("notification");
// Message is a bit weird because values (2 and 4 bytes) are simplified to 2 decimals in regards to megabytes
expect(message).toBe(
"The selected file (4B) is larger than the maximum allowed file size (2B)."
);
},
});
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
await contains(".o_file_input input", { visible: false }).click();
await setInputFiles([file]);
await animationFrame();
expect.verifySteps(["notification"]);
});
test("Upload button is disabled if attachment upload is not finished", async () => {
const uploadedPromise = new Deferred();
await createFileInput({
mockPost: async (route) => {
if (route === "/web/binary/upload_attachment") {
await uploadedPromise;
}
return "[]";
},
props: {},
});
//enable button
await contains(".o_file_input input", { visible: false }).click();
await setInputFiles([]);
await animationFrame();
//disable button
expect(".o_file_input input").not.toBeEnabled({
message: "the upload button should be disabled on upload",
});
uploadedPromise.resolve();
await animationFrame();
expect(".o_file_input input").toBeEnabled({
message: "the upload button should be enabled for upload",
});
});

View file

@ -0,0 +1,20 @@
import { expect, test } from "@odoo/hoot";
import { FileModel } from "@web/core/file_viewer/file_model";
test("url query params of FileModel returns proper params", () => {
const attachmentData = {
access_token: "4b52e31e-a155-4598-8d15-538f64f0fb7b",
checksum: "f6a9d2bcbb34ce90a73785d8c8d1b82e5cdf0b5b",
extension: "jpg",
name: "test.jpg",
mimetype: "image/jpeg",
};
const expectedQueryParams = {
access_token: "4b52e31e-a155-4598-8d15-538f64f0fb7b",
filename: "test.jpg",
unique: "f6a9d2bcbb34ce90a73785d8c8d1b82e5cdf0b5b",
};
const fileModel = Object.assign(new FileModel(), attachmentData);
expect(fileModel.urlQueryParams).toEqual(expectedQueryParams);
});

View file

@ -0,0 +1,127 @@
import { expect, test } from "@odoo/hoot";
import {
contains,
getService,
mockService,
mountWithCleanup,
onRpc,
} from "@web/../tests/web_test_helpers";
import { Deferred, animationFrame } from "@odoo/hoot-mock";
import { FileUploadProgressContainer } from "@web/core/file_upload/file_upload_progress_container";
import { FileUploadProgressRecord } from "@web/core/file_upload/file_upload_progress_record";
import { useService } from "@web/core/utils/hooks";
import { Component, xml } from "@odoo/owl";
class FileUploadProgressTestRecord extends FileUploadProgressRecord {
static template = xml`
<t t-set="progressTexts" t-value="getProgressTexts()"/>
<div class="file_upload">
<div class="file_upload_progress_text_left" t-esc="progressTexts.left"/>
<div class="file_upload_progress_text_right" t-esc="progressTexts.right"/>
<FileUploadProgressBar fileUpload="props.fileUpload"/>
</div>
`;
}
class Parent extends Component {
static components = {
FileUploadProgressContainer,
};
static template = xml`
<div class="parent">
<FileUploadProgressContainer fileUploads="fileUploadService.uploads" shouldDisplay="props.shouldDisplay" Component="FileUploadProgressTestRecord"/>
</div>
`;
static props = ["*"];
setup() {
this.fileUploadService = useService("file_upload");
this.FileUploadProgressTestRecord = FileUploadProgressTestRecord;
}
}
onRpc("/test/", () => new Deferred());
test("can be rendered", async () => {
await mountWithCleanup(Parent);
expect(".parent").toHaveCount(1);
expect(".file_upload").toHaveCount(0);
});
test("upload renders new component(s)", async () => {
await mountWithCleanup(Parent);
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await animationFrame();
expect(".file_upload").toHaveCount(1);
fileUploadService.upload("/test/", []);
await animationFrame();
expect(".file_upload").toHaveCount(2);
});
test("upload end removes component", async () => {
await mountWithCleanup(Parent);
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await animationFrame();
fileUploadService.uploads[1].xhr.dispatchEvent(new Event("load"));
await animationFrame();
expect(".file_upload").toHaveCount(0);
});
test("upload error removes component", async () => {
await mountWithCleanup(Parent);
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await animationFrame();
fileUploadService.uploads[1].xhr.dispatchEvent(new Event("error"));
await animationFrame();
expect(".file_upload").toHaveCount(0);
});
test("upload abort removes component", async () => {
await mountWithCleanup(Parent);
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await animationFrame();
fileUploadService.uploads[1].xhr.dispatchEvent(new Event("abort"));
await animationFrame();
expect(".file_upload").toHaveCount(0);
});
test("upload can be aborted by clicking on cross", async () => {
mockService("dialog", {
add() {
fileUploadService.uploads[1].xhr.dispatchEvent(new Event("abort"));
},
});
await mountWithCleanup(Parent);
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await animationFrame();
await contains(".o-file-upload-progress-bar-abort", { visible: false }).click();
await animationFrame();
expect(".file_upload").toHaveCount(0);
});
test("upload updates on progress", async () => {
await mountWithCleanup(Parent);
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await animationFrame();
const progressEvent = new Event("progress", { bubbles: true });
progressEvent.loaded = 250000000;
progressEvent.total = 500000000;
fileUploadService.uploads[1].xhr.upload.dispatchEvent(progressEvent);
await animationFrame();
expect(".file_upload_progress_text_left").toHaveText("Uploading... (50%)");
progressEvent.loaded = 350000000;
fileUploadService.uploads[1].xhr.upload.dispatchEvent(progressEvent);
await animationFrame();
expect(".file_upload_progress_text_right").toHaveText("(350/500MB)");
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,115 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { animationFrame, mockFetch } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import {
contains,
makeMockEnv,
mountWithCleanup,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import { InstallScopedApp } from "@web/core/install_scoped_app/install_scoped_app";
const mountManifestLink = (href) => {
const fixture = getFixture();
const manifestLink = document.createElement("link");
manifestLink.rel = "manifest";
manifestLink.href = href;
fixture.append(manifestLink);
};
test("Installation page displays the app info correctly", async () => {
const beforeInstallPromptEvent = new CustomEvent("beforeinstallprompt");
beforeInstallPromptEvent.preventDefault = () => {};
beforeInstallPromptEvent.prompt = async () => ({ outcome: "accepted" });
browser.BeforeInstallPromptEvent = beforeInstallPromptEvent;
await makeMockEnv();
patchWithCleanup(browser.location, {
replace: (url) => {
expect(url.searchParams.get("app_name")).toBe("%3COtto%26", {
message: "ask to redirect with updated searchParams",
});
expect.step("URL replace");
},
});
mountManifestLink("/web/manifest.scoped_app_manifest");
mockFetch((route) => {
expect.step(route);
return {
icons: [
{
src: "/fake_image_src",
sizes: "any",
type: "image/png",
},
],
name: "My App",
scope: "/scoped_app/myApp",
start_url: "/scoped_app/myApp",
};
});
class Parent extends Component {
static props = ["*"];
static components = { InstallScopedApp };
static template = xml`<InstallScopedApp/>`;
}
await mountWithCleanup(Parent);
expect.verifySteps(["/web/manifest.scoped_app_manifest"]);
await animationFrame();
expect(".o_install_scoped_app").toHaveCount(1);
expect(".o_install_scoped_app h1").toHaveText("My App");
expect(".o_install_scoped_app img").toHaveAttribute("data-src", "/fake_image_src");
expect(".fa-pencil").toHaveCount(0);
expect("button.btn-primary").toHaveCount(0);
expect("div.bg-info").toHaveCount(1);
expect("div.bg-info").toHaveText("You can install the app from the browser menu");
browser.dispatchEvent(beforeInstallPromptEvent);
await animationFrame();
expect(".fa-pencil").toHaveCount(1);
expect("div.bg-info").toHaveCount(0);
expect("button.btn-primary").toHaveCount(1);
expect("button.btn-primary").toHaveText("Install");
await contains(".fa-pencil").click();
await contains("input").edit("<Otto&");
expect.verifySteps(["URL replace"]);
});
test("Installation page displays the error message when browser is not supported", async () => {
delete browser.BeforeInstallPromptEvent;
await makeMockEnv();
mountManifestLink("/web/manifest.scoped_app_manifest");
mockFetch((route) => {
expect.step(route);
return {
icons: [
{
src: "/fake_image_src",
sizes: "any",
type: "image/png",
},
],
name: "My App",
scope: "/scoped_app/myApp",
start_url: "/scoped_app/myApp",
};
});
class Parent extends Component {
static props = ["*"];
static components = { InstallScopedApp };
static template = xml`<InstallScopedApp/>`;
}
await mountWithCleanup(Parent);
expect.verifySteps(["/web/manifest.scoped_app_manifest"]);
await animationFrame();
expect(".o_install_scoped_app").toHaveCount(1);
expect(".o_install_scoped_app h1").toHaveText("My App");
expect(".o_install_scoped_app img").toHaveAttribute("data-src", "/fake_image_src");
expect("button.btn-primary").toHaveCount(0);
expect("div.bg-info").toHaveCount(1);
expect("div.bg-info").toHaveText("The app cannot be installed with this browser");
});

View file

@ -0,0 +1,670 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { mockDate, mockTimeZone } from "@odoo/hoot-mock";
import {
defineParams,
makeMockEnv,
patchTranslations,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import {
deserializeDate,
deserializeDateTime,
formatDate,
formatDateTime,
parseDate,
parseDateTime,
serializeDate,
serializeDateTime,
strftimeToLuxonFormat,
} from "@web/core/l10n/dates";
import { localization } from "@web/core/l10n/localization";
const { DateTime, Settings } = luxon;
const formats = {
date: "%d.%m/%Y",
time: "%H:%M:%S",
};
const dateFormat = strftimeToLuxonFormat(formats.date);
const timeFormat = strftimeToLuxonFormat(formats.time);
beforeEach(() => {
patchTranslations();
});
test("formatDate/formatDateTime specs", async () => {
patchWithCleanup(localization, {
dateFormat: "MM/dd/yyyy",
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
});
mockDate("2009-05-04 11:34:56", +1);
const utc = DateTime.utc(); // 2009-05-04T11:34:56.000Z
const local = DateTime.local(); // 2009-05-04T12:34:56.000+01:00
const minus13FromLocalTZ = local.setZone("UTC-12"); // 2009-05-03T23:34:56.000-12:00
// For dates, regardless of the input timezone, outputs only the date
expect(formatDate(utc)).toBe("05/04/2009");
expect(formatDate(local)).toBe("05/04/2009");
expect(formatDate(minus13FromLocalTZ)).toBe("05/03/2009");
// For datetimes, input timezone is taken into account, outputs in local timezone
expect(formatDateTime(utc)).toBe("05/04/2009 12:34:56");
expect(formatDateTime(local)).toBe("05/04/2009 12:34:56");
expect(formatDateTime(minus13FromLocalTZ)).toBe("05/04/2009 12:34:56");
});
test("formatDate/formatDateTime specs, at midnight", async () => {
patchWithCleanup(localization, {
dateFormat: "MM/dd/yyyy",
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
});
mockDate("2009-05-03 23:00:00", +1);
const utc = DateTime.utc(); // 2009-05-03T23:00:00.000Z
const local = DateTime.local(); // 2009-05-04T00:00:00.000+01:00
const minus13FromLocalTZ = local.setZone("UTC-12"); // 2009-05-03T11:00:00.000-12:00
// For dates, regardless of the input timezone, outputs only the date
expect(formatDate(utc)).toBe("05/03/2009");
expect(formatDate(local)).toBe("05/04/2009");
expect(formatDate(minus13FromLocalTZ)).toBe("05/03/2009");
// For datetimes, input timezone is taken into account, outputs in local timezone
expect(formatDateTime(utc)).toBe("05/04/2009 00:00:00");
expect(formatDateTime(local)).toBe("05/04/2009 00:00:00");
expect(formatDateTime(minus13FromLocalTZ)).toBe("05/04/2009 00:00:00");
});
test("formatDate/formatDateTime with condensed option", async () => {
mockDate("2009-05-03 08:00:00");
mockTimeZone(0);
const now = DateTime.now();
patchWithCleanup(localization, {
dateFormat: "MM/dd/yyyy",
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
});
expect(formatDate(now, { condensed: true })).toBe("5/3/2009");
expect(formatDateTime(now, { condensed: true })).toBe("5/3/2009 8:00:00");
patchWithCleanup(localization, { dateFormat: "yyyy-MM-dd" });
expect(formatDate(now, { condensed: true })).toBe("2009-5-3");
patchWithCleanup(localization, { dateFormat: "dd MMM yy" });
expect(formatDate(now, { condensed: true })).toBe("3 May 09");
});
test("formatDateTime in different timezone", async () => {
patchWithCleanup(localization, {
dateFormat: "MM/dd/yyyy",
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
});
mockDate("2009-05-04 00:00:00", 0);
expect(formatDateTime(DateTime.utc())).toBe("05/04/2009 00:00:00");
expect(formatDateTime(DateTime.utc(), { tz: "Asia/Kolkata" })).toBe("05/04/2009 05:30:00");
});
test("parseDate(Time) outputs DateTime objects in local TZ", async () => {
await makeMockEnv();
mockTimeZone(+1);
expect(parseDate("01/13/2019").toISO()).toBe("2019-01-13T00:00:00.000+01:00");
expect(parseDateTime("01/13/2019 10:05:45").toISO()).toBe("2019-01-13T10:05:45.000+01:00");
mockTimeZone(+5.5);
expect(parseDate("01/13/2019").toISO()).toBe("2019-01-13T00:00:00.000+05:30");
expect(parseDateTime("01/13/2019 10:05:45").toISO()).toBe("2019-01-13T10:05:45.000+05:30");
mockTimeZone(-11);
expect(parseDate("01/13/2019").toISO()).toBe("2019-01-13T00:00:00.000-11:00");
expect(parseDateTime("01/13/2019 10:05:45").toISO()).toBe("2019-01-13T10:05:45.000-11:00");
});
test("parseDateTime in different timezone", async () => {
await makeMockEnv();
mockTimeZone(+1);
expect(parseDateTime("01/13/2019 10:05:45").toISO()).toBe("2019-01-13T10:05:45.000+01:00");
expect(parseDateTime("01/13/2019 10:05:45", { tz: "Asia/Kolkata" }).toISO()).toBe(
"2019-01-13T10:05:45.000+05:30"
);
});
test("parseDate with different numbering system", async () => {
patchWithCleanup(localization, {
dateFormat: "dd MMM, yyyy",
dateTimeFormat: "dd MMM, yyyy hh:mm:ss",
timeFormat: "hh:mm:ss",
});
patchWithCleanup(Settings, { defaultNumberingSystem: "arab", defaultLocale: "ar" });
expect(parseDate("٠١ فبراير, ٢٠٢٣").toISO()).toBe("2023-02-01T00:00:00.000+01:00");
});
test("parseDateTime", async () => {
expect(() => parseDateTime("13/01/2019 12:00:00")).toThrow(/is not a correct/, {
message: "Wrongly formated dates should be invalid",
});
expect(() => parseDateTime("01/01/0999 12:00:00")).toThrow(/is not a correct/, {
message: "Dates before 1000 should be invalid",
});
expect(() => parseDateTime("01/01/10000 12:00:00")).toThrow(/is not a correct/, {
message: "Dates after 9999 should be invalid",
});
expect(() => parseDateTime("invalid value")).toThrow(/is not a correct/);
const expected = "2019-01-13T10:05:45.000+01:00";
expect(parseDateTime("01/13/2019 10:05:45").toISO()).toBe(expected, {
message: "Date with leading 0",
});
expect(parseDateTime("1/13/2019 10:5:45").toISO()).toBe(expected, {
message: "Date without leading 0",
});
});
test("parseDateTime (norwegian locale)", async () => {
defineParams({
lang: "no", // Norwegian
lang_parameters: {
date_format: "%d. %b %Y",
time_format: "%H:%M:%S",
},
});
await makeMockEnv();
expect(parseDateTime("16. des 2019 10:05:45").toISO()).toBe("2019-12-16T10:05:45.000+01:00", {
message: "Day/month inverted + month i18n",
});
});
test("parseDate", async () => {
await makeMockEnv();
expect(parseDate("07/21/2022").toISO()).toBe("2022-07-21T00:00:00.000+01:00");
expect(parseDate("07/22/2022").toISO()).toBe("2022-07-22T00:00:00.000+01:00");
});
test("parseDate without separator", async () => {
const dateFormat = strftimeToLuxonFormat("%d.%m/%Y");
const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
patchWithCleanup(localization, {
dateFormat,
timeFormat,
dateTimeFormat: `${dateFormat} ${timeFormat}`,
});
const testDateFormat = "dd.MM/yyyy";
expect(() => parseDate("1137")).toThrow(/is not a correct/, {
message: "Wrongly formated dates should be invalid",
});
expect(() => parseDate("1197")).toThrow(/is not a correct/, {
message: "Wrongly formated dates should be invalid",
});
expect(() => parseDate("0131")).toThrow(/is not a correct/, {
message: "Wrongly formated dates should be invalid",
});
expect(() => parseDate("970131")).toThrow(/is not a correct/, {
message: "Wrongly formated dates should be invalid",
});
expect(parseDate("2001").toFormat(testDateFormat)).toBe("20.01/" + DateTime.utc().year);
expect(parseDate("3101").toFormat(testDateFormat)).toBe("31.01/" + DateTime.utc().year);
expect(parseDate("31.01").toFormat(testDateFormat)).toBe("31.01/" + DateTime.utc().year);
expect(parseDate("310197").toFormat(testDateFormat)).toBe("31.01/1997");
expect(parseDate("310117").toFormat(testDateFormat)).toBe("31.01/2017");
expect(parseDate("31011985").toFormat(testDateFormat)).toBe("31.01/1985");
});
test("parseDateTime without separator", async () => {
const dateFormat = strftimeToLuxonFormat("%d.%m/%Y");
const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
patchWithCleanup(localization, {
dateFormat,
timeFormat,
dateTimeFormat: `${dateFormat} ${timeFormat}`,
});
const dateTimeFormat = "dd.MM/yyyy HH:mm/ss";
expect(parseDateTime("3101198508").toFormat(dateTimeFormat)).toBe("31.01/1985 08:00/00");
expect(parseDateTime("310119850833").toFormat(dateTimeFormat)).toBe("31.01/1985 08:33/00");
expect(parseDateTime("31/01/1985 08").toFormat(dateTimeFormat)).toBe("31.01/1985 08:00/00");
});
test("parseDateTime with escaped characters (eg. Basque locale)", async () => {
const dateFormat = strftimeToLuxonFormat("%a, %Y.eko %bren %da");
const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
patchWithCleanup(localization, {
dateFormat,
timeFormat,
dateTimeFormat: `${dateFormat} ${timeFormat}`,
});
const dateTimeFormat = `${dateFormat} ${timeFormat}`;
expect(dateTimeFormat).toBe("ccc, yyyy.'e''k''o' MMM'r''e''n' dd'a' HH:mm:ss");
expect(parseDateTime("1985-01-31 08:30:00").toFormat(dateTimeFormat)).toBe(
"Thu, 1985.eko Janren 31a 08:30:00"
);
});
test("parse smart date input", async () => {
mockDate("2020-01-01 00:00:00", 0);
const format = "yyyy-MM-dd HH:mm";
// with parseDate
expect(parseDate("+0").toFormat(format)).toBe("2020-01-01 00:00");
expect(parseDate("-0").toFormat(format)).toBe("2020-01-01 00:00");
expect(parseDate("+1d").toFormat(format)).toBe("2020-01-02 00:00");
expect(parseDate("+2w").toFormat(format)).toBe("2020-01-15 00:00");
expect(parseDate("+3m").toFormat(format)).toBe("2020-04-01 00:00");
expect(parseDate("+4y").toFormat(format)).toBe("2024-01-01 00:00");
expect(parseDate("+5").toFormat(format)).toBe("2020-01-06 00:00");
expect(parseDate("-5").toFormat(format)).toBe("2019-12-27 00:00");
expect(parseDate("-4y").toFormat(format)).toBe("2016-01-01 00:00");
expect(parseDate("-3m").toFormat(format)).toBe("2019-10-01 00:00");
expect(parseDate("-2w").toFormat(format)).toBe("2019-12-18 00:00");
expect(parseDate("-1d").toFormat(format)).toBe("2019-12-31 00:00");
// with parseDateTime
expect(parseDateTime("+0").toFormat(format)).toBe("2020-01-01 00:00");
expect(parseDateTime("-0").toFormat(format)).toBe("2020-01-01 00:00");
expect(parseDateTime("+1d").toFormat(format)).toBe("2020-01-02 00:00");
expect(parseDateTime("+2w").toFormat(format)).toBe("2020-01-15 00:00");
expect(parseDateTime("+3m").toFormat(format)).toBe("2020-04-01 00:00");
expect(parseDateTime("+4y").toFormat(format)).toBe("2024-01-01 00:00");
expect(parseDateTime("+5").toFormat(format)).toBe("2020-01-06 00:00");
expect(parseDateTime("-5").toFormat(format)).toBe("2019-12-27 00:00");
expect(parseDateTime("-4y").toFormat(format)).toBe("2016-01-01 00:00");
expect(parseDateTime("-3m").toFormat(format)).toBe("2019-10-01 00:00");
expect(parseDateTime("-2w").toFormat(format)).toBe("2019-12-18 00:00");
expect(parseDateTime("-1d").toFormat(format)).toBe("2019-12-31 00:00");
});
test("parseDateTime ISO8601 Format", async () => {
mockTimeZone(+1);
expect(parseDateTime("2017-05-15T12:00:00.000+06:00").toISO()).toBe(
"2017-05-15T07:00:00.000+01:00"
);
// without the 'T' separator is not really ISO8601 compliant, but we still support it
expect(parseDateTime("2017-05-15 12:00:00.000+06:00").toISO()).toBe(
"2017-05-15T07:00:00.000+01:00"
);
});
test("parseDateTime SQL Format", async () => {
expect(parseDateTime("2017-05-15 09:12:34").toISO()).toBe("2017-05-15T09:12:34.000+01:00");
expect(parseDateTime("2017-05-08 09:12:34").toISO()).toBe("2017-05-08T09:12:34.000+01:00");
});
test("serializeDate", async () => {
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
expect(date.toFormat("yyyy-MM-dd")).toBe("2022-02-21");
expect(serializeDate(date)).toBe("2022-02-21");
});
test("serializeDate, with DateTime.now()", async () => {
mockDate("2022-02-21 15:11:42");
const date = DateTime.now();
expect(date.toFormat("yyyy-MM-dd")).toBe("2022-02-21");
expect(serializeDate(date)).toBe("2022-02-21");
});
test("serializeDate, with DateTime.now(), midnight", async () => {
mockDate("2022-02-20 23:00:00");
const date = DateTime.now();
expect(date.toFormat("yyyy-MM-dd")).toBe("2022-02-21");
expect(serializeDate(date)).toBe("2022-02-21");
});
test("serializeDate with different numbering system", async () => {
patchWithCleanup(Settings, { defaultNumberingSystem: "arab" });
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
expect(date.toFormat("yyyy-MM-dd")).toBe("٢٠٢٢-٠٢-٢١");
expect(serializeDate(date)).toBe("2022-02-21");
});
test("serializeDateTime", async () => {
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
expect(date.toFormat("yyyy-MM-dd HH:mm:ss")).toBe("2022-02-21 16:11:42");
expect(serializeDateTime(date)).toBe("2022-02-21 16:11:42");
});
test("serializeDateTime, with DateTime.now()", async () => {
mockDate("2022-02-21 15:11:42");
const date = DateTime.now();
expect(date.toFormat("yyyy-MM-dd HH:mm:ss")).toBe("2022-02-21 16:11:42");
expect(serializeDateTime(date)).toBe("2022-02-21 15:11:42");
});
test("serializeDateTime, with DateTime.now(), midnight", async () => {
mockDate("2022-02-20 23:00:00");
const date = DateTime.now();
expect(date.toFormat("yyyy-MM-dd HH:mm:ss")).toBe("2022-02-21 00:00:00");
expect(serializeDateTime(date)).toBe("2022-02-20 23:00:00");
});
test("serializeDateTime with different numbering system", async () => {
patchWithCleanup(Settings, { defaultNumberingSystem: "arab" });
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
expect(date.toFormat("yyyy-MM-dd HH:mm:ss")).toBe("٢٠٢٢-٠٢-٢١ ١٦:١١:٤٢");
expect(serializeDateTime(date)).toBe("2022-02-21 16:11:42");
});
test("deserializeDate", async () => {
const date = DateTime.local(2022, 2, 21);
expect(DateTime.fromFormat("2022-02-21", "yyyy-MM-dd").toMillis()).toBe(date.toMillis());
expect(deserializeDate("2022-02-21").toMillis()).toBe(date.toMillis());
});
test("deserializeDate with different numbering system", async () => {
patchWithCleanup(Settings, { defaultNumberingSystem: "arab" });
const date = DateTime.local(2022, 2, 21);
expect(DateTime.fromFormat("٢٠٢٢-٠٢-٢١", "yyyy-MM-dd").toMillis()).toBe(date.toMillis());
expect(deserializeDate("2022-02-21").toMillis()).toBe(date.toMillis());
});
test("deserializeDateTime", async () => {
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
expect(
DateTime.fromFormat("2022-02-21 16:11:42", "yyyy-MM-dd HH:mm:ss", {
zone: "utc",
}).toMillis()
).toBe(date.toMillis());
expect(deserializeDateTime("2022-02-21 16:11:42").toMillis()).toBe(date.toMillis());
});
test("deserializeDateTime with different numbering system", async () => {
patchWithCleanup(Settings, { defaultNumberingSystem: "arab" });
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
expect(
DateTime.fromFormat("٢٠٢٢-٠٢-٢١ ١٦:١١:٤٢", "yyyy-MM-dd HH:mm:ss", {
zone: "utc",
}).toMillis()
).toBe(date.toMillis());
expect(deserializeDateTime("2022-02-21 16:11:42").toMillis()).toBe(date.toMillis());
});
test("deserializeDateTime with different timezone", async () => {
const date = DateTime.utc(2022, 2, 21, 16, 11, 42).setZone("Europe/Brussels");
expect(deserializeDateTime("2022-02-21 16:11:42", { tz: "Europe/Brussels" }).c).toEqual(date.c);
});
test("parseDate with short notations", async () => {
expect(parseDate("20-10-20", { format: "yyyy-MM-dd" }).toISO()).toBe(
"2020-10-20T00:00:00.000+01:00"
);
expect(parseDate("20/10/20", { format: "yyyy/MM/dd" }).toISO()).toBe(
"2020-10-20T00:00:00.000+01:00"
);
expect(parseDate("10-20-20", { format: "MM-dd-yyyy" }).toISO()).toBe(
"2020-10-20T00:00:00.000+01:00"
);
expect(parseDate("10-20-20", { format: "MM-yyyy-dd" }).toISO()).toBe(
"2020-10-20T00:00:00.000+01:00"
);
expect(parseDate("1-20-2", { format: "MM-yyyy-dd" }).toISO()).toBe(
"2020-01-02T00:00:00.000+01:00"
);
expect(parseDate("20/1/2", { format: "yyyy/MM/dd" }).toISO()).toBe(
"2020-01-02T00:00:00.000+01:00"
);
});
test("parseDateTime with short notations", async () => {
expect(parseDateTime("20-10-20 8:5:3", { format: "yyyy-MM-dd hh:mm:ss" }).toISO()).toBe(
"2020-10-20T08:05:03.000+01:00"
);
});
test("parseDate with textual month notation", async () => {
patchWithCleanup(localization, {
dateFormat: "MMM/dd/yyyy",
});
expect(parseDate("Jan/05/1997").toISO()).toBe("1997-01-05T00:00:00.000+01:00");
expect(parseDate("Jan/05/1997", { format: undefined }).toISO()).toBe(
"1997-01-05T00:00:00.000+01:00"
);
expect(parseDate("Jan/05/1997", { format: "MMM/dd/yyyy" }).toISO()).toBe(
"1997-01-05T00:00:00.000+01:00"
);
});
test("parseDate (various entries)", async () => {
mockDate("2020-07-15 12:30:00", 0);
patchWithCleanup(localization, {
dateFormat,
timeFormat,
dateTimeFormat: `${dateFormat} ${timeFormat}`,
});
/**
* Type of testSet key: string
* Type of testSet value: string | undefined
*/
const testSet = new Map([
["10101010101010", undefined],
["1191111", "1191-04-21T00:00:00.000Z"], // day 111 of year 1191
["11911111", "1191-11-11T00:00:00.000Z"],
["3101", "2020-01-31T00:00:00.000Z"],
["310160", "2060-01-31T00:00:00.000Z"],
["311260", "2060-12-31T00:00:00.000Z"],
["310161", "1961-01-31T00:00:00.000Z"],
["310165", "1965-01-31T00:00:00.000Z"],
["310168", "1968-01-31T00:00:00.000Z"],
["311268", "1968-12-31T00:00:00.000Z"],
["310169", "1969-01-31T00:00:00.000Z"],
["310170", "1970-01-31T00:00:00.000Z"],
["310197", "1997-01-31T00:00:00.000Z"],
["310117", "2017-01-31T00:00:00.000Z"],
["31011985", "1985-01-31T00:00:00.000Z"],
["3101198508", undefined],
["310119850833", undefined],
["1137", undefined],
["1197", undefined],
["0131", undefined],
["0922", undefined],
["2020", undefined],
["199901", "1999-01-01T00:00:00.000Z"],
["30100210", "3010-02-10T00:00:00.000Z"],
["3010210", "3010-07-29T00:00:00.000Z"],
["970131", undefined],
["31.01", "2020-01-31T00:00:00.000Z"],
["31/01/1985 08", undefined],
["01121934", "1934-12-01T00:00:00.000Z"],
["011234", "2034-12-01T00:00:00.000Z"],
["011260", "2060-12-01T00:00:00.000Z"],
["2", "2020-07-02T00:00:00.000Z"],
["02", "2020-07-02T00:00:00.000Z"],
["20", "2020-07-20T00:00:00.000Z"],
["202", "2020-02-20T00:00:00.000Z"],
["2002", "2020-02-20T00:00:00.000Z"],
["0202", "2020-02-02T00:00:00.000Z"],
["02/02", "2020-02-02T00:00:00.000Z"],
["02/13", undefined],
["02/1313", undefined],
["09990101", undefined],
["19990101", "1999-01-01T00:00:00.000Z"],
["19990130", "1999-01-30T00:00:00.000Z"],
["19991230", "1999-12-30T00:00:00.000Z"],
["19993012", undefined],
["2016-200", "2016-07-18T00:00:00.000Z"],
["2016200", "2016-07-18T00:00:00.000Z"], // day 200 of year 2016
["2020-", undefined],
["2020-W2", undefined],
["2020W23", "2020-06-01T00:00:00.000Z"],
["2020-W02", "2020-01-06T00:00:00.000Z"],
["2020-W32", "2020-08-03T00:00:00.000Z"],
["2020-W32-3", "2020-08-05T00:00:00.000Z"],
["2016-W21-3", "2016-05-25T00:00:00.000Z"],
["2016W213", "2016-05-25T00:00:00.000Z"],
["2209", "2020-09-22T00:00:00.000Z"],
["22:09", "2020-09-22T00:00:00.000Z"],
["2012", "2020-12-20T00:00:00.000Z"],
["2016-01-03 09:24:15.123", "2016-01-03T00:00:00.000Z"],
["2016-01-03T09:24:15.123", "2016-01-03T00:00:00.000Z"],
["2016-01-03T09:24:15.123+06:00", "2016-01-03T00:00:00.000Z"],
["2016-01-03T09:24:15.123+16:00", "2016-01-02T00:00:00.000Z"],
["2016-01-03T09:24:15.123Z", "2016-01-03T00:00:00.000Z"],
["2016-W21-3T09:24:15.123", "2016-05-25T00:00:00.000Z"],
["2016-W21-3 09:24:15.123", undefined],
["2016-03-27T02:00:00.000+02:00", "2016-03-27T00:00:00.000Z"],
["2016-03-27T03:00:00.000+02:00", "2016-03-27T00:00:00.000Z"],
["2016-03-27T02:00:00.000", "2016-03-27T00:00:00.000Z"],
["2016-03-27T03:00:00.000", "2016-03-27T00:00:00.000Z"],
["2016-03-27T02:00:00.000Z", "2016-03-27T00:00:00.000Z"],
["2016-03-27T03:00:00.000Z", "2016-03-27T00:00:00.000Z"],
["09:22", undefined],
["2013", undefined],
["011261", "1961-12-01T00:00:00.000Z"],
["932-10-10", undefined], // year < 1000 are not supported
["1932-10-10", "1932-10-10T00:00:00.000Z"],
["2016-01-03 09:24:15.123+06:00", "2016-01-03T00:00:00.000Z"],
["2016-01-03 09:24:15.123+16:00", "2016-01-02T00:00:00.000Z"],
["2016-01-03 09:24:15.123Z", "2016-01-03T00:00:00.000Z"],
]);
for (const [input, expected] of testSet.entries()) {
if (!expected) {
expect(() => parseDate(input).toISO()).toThrow(/is not a correct/);
} else {
expect(parseDate(input).toISO()).toBe(expected);
}
}
});
test("parseDateTime (various entries)", async () => {
mockDate("2020-07-15 11:30:00", 0);
patchWithCleanup(localization, {
dateFormat,
timeFormat,
dateTimeFormat: `${dateFormat} ${timeFormat}`,
});
/**
* Type of testSet key: string
* Type of testSet value: string | undefined
*/
const testSet = new Map([
["10101010101010", "1010-10-10T10:10:10.000Z"],
["1191111", "1191-04-21T00:00:00.000Z"], // day 111 of year 1191
["11911111", "1191-11-11T00:00:00.000Z"],
["3101", "2020-01-31T00:00:00.000Z"],
["310160", "2060-01-31T00:00:00.000Z"],
["311260", "2060-12-31T00:00:00.000Z"],
["310161", "1961-01-31T00:00:00.000Z"],
["310165", "1965-01-31T00:00:00.000Z"],
["310168", "1968-01-31T00:00:00.000Z"],
["311268", "1968-12-31T00:00:00.000Z"],
["310169", "1969-01-31T00:00:00.000Z"],
["310170", "1970-01-31T00:00:00.000Z"],
["310197", "1997-01-31T00:00:00.000Z"],
["310117", "2017-01-31T00:00:00.000Z"],
["31011985", "1985-01-31T00:00:00.000Z"],
["3101198508", "1985-01-31T08:00:00.000Z"],
["310119850833", "1985-01-31T08:33:00.000Z"],
["1137", undefined],
["1197", undefined],
["0131", undefined],
["0922", undefined],
["2020", undefined],
["199901", "1999-01-01T00:00:00.000Z"],
["30100210", "3010-02-10T00:00:00.000Z"],
["3010210", "3010-07-29T00:00:00.000Z"],
["970131", undefined],
["31.01", "2020-01-31T00:00:00.000Z"],
["31/01/1985 08", "1985-01-31T08:00:00.000Z"],
["01121934", "1934-12-01T00:00:00.000Z"],
["011234", "2034-12-01T00:00:00.000Z"],
["011260", "2060-12-01T00:00:00.000Z"],
["2", "2020-07-02T00:00:00.000Z"],
["02", "2020-07-02T00:00:00.000Z"],
["20", "2020-07-20T00:00:00.000Z"],
["202", "2020-02-20T00:00:00.000Z"],
["2002", "2020-02-20T00:00:00.000Z"],
["0202", "2020-02-02T00:00:00.000Z"],
["02/02", "2020-02-02T00:00:00.000Z"],
["02/13", undefined],
["02/1313", undefined],
["09990101", undefined],
["19990101", "1999-01-01T00:00:00.000Z"],
["19990130", "1999-01-30T00:00:00.000Z"],
["19991230", "1999-12-30T00:00:00.000Z"],
["19993012", undefined],
["2016-200", "2016-07-18T00:00:00.000Z"],
["2016200", "2016-07-18T00:00:00.000Z"], // day 200 of year 2016
["2020-", undefined],
["2020-W2", undefined],
["2020W23", "2020-06-01T00:00:00.000Z"],
["2020-W02", "2020-01-06T00:00:00.000Z"],
["2020-W32", "2020-08-03T00:00:00.000Z"],
["2020-W32-3", "2020-08-05T00:00:00.000Z"],
["2016-W21-3", "2016-05-25T00:00:00.000Z"],
["2016W213", "2016-05-25T00:00:00.000Z"],
["2209", "2020-09-22T00:00:00.000Z"],
["22:09", "2020-09-22T00:00:00.000Z"],
["2012", "2020-12-20T00:00:00.000Z"],
["2016-01-03 09:24:15.123", "2016-01-03T09:24:15.123Z"],
["2016-01-03T09:24:15.123", "2016-01-03T09:24:15.123Z"],
["2016-01-03T09:24:15.123+06:00", "2016-01-03T03:24:15.123Z"],
["2016-01-03T09:24:15.123+16:00", "2016-01-02T17:24:15.123Z"],
["2016-01-03T09:24:15.123Z", "2016-01-03T09:24:15.123Z"],
["2016-W21-3T09:24:15.123", "2016-05-25T09:24:15.123Z"],
["2016-W21-3 09:24:15.123", undefined],
["2016-03-27T02:00:00.000+02:00", "2016-03-27T00:00:00.000Z"],
["2016-03-27T03:00:00.000+02:00", "2016-03-27T01:00:00.000Z"],
["2016-03-27T02:00:00.000", "2016-03-27T02:00:00.000Z"],
["2016-03-27T03:00:00.000", "2016-03-27T03:00:00.000Z"],
["2016-03-27T02:00:00.000Z", "2016-03-27T02:00:00.000Z"],
["2016-03-27T03:00:00.000Z", "2016-03-27T03:00:00.000Z"],
["09:22", undefined],
["2013", undefined],
["011261", "1961-12-01T00:00:00.000Z"],
["932-10-10", undefined],
["1932-10-10", "1932-10-10T00:00:00.000Z"],
["2016-01-03 09:24:15.123+06:00", "2016-01-03T03:24:15.123Z"],
["2016-01-03 09:24:15.123+16:00", "2016-01-02T17:24:15.123Z"],
["2016-01-03 09:24:15.123Z", "2016-01-03T09:24:15.123Z"],
]);
for (const [input, expected] of testSet.entries()) {
if (!expected) {
expect(() => parseDateTime(input).toISO()).toThrow(/is not a correct/);
} else {
expect(parseDateTime(input).toISO()).toBe(expected);
}
}
});
test("parseDateTime: arab locale, latin numbering system as input", async () => {
defineParams({
lang: "ar_001",
lang_parameters: {
date_format: "%d %b, %Y",
time_format: "%H:%M:%S",
},
});
await makeMockEnv();
// Check it works with arab
expect(parseDateTime("١٥ يوليو, ٢٠٢٠ ١٢:٣٠:٤٣").toISO().split(".")[0]).toBe(
"2020-07-15T12:30:43"
);
// Check it also works with latin numbers
expect(parseDateTime("15 07, 2020 12:30:43").toISO().split(".")[0]).toBe("2020-07-15T12:30:43");
expect(parseDateTime("22/01/2023").toISO().split(".")[0]).toBe("2023-01-22T00:00:00");
expect(parseDateTime("2023-01-22").toISO().split(".")[0]).toBe("2023-01-22T00:00:00");
});

View file

@ -0,0 +1,189 @@
import { after, describe, expect, test } from "@odoo/hoot";
import {
defineParams,
makeMockEnv,
mountWithCleanup,
onRpc,
patchTranslations,
patchWithCleanup,
serverState,
} from "@web/../tests/web_test_helpers";
import { _t, translatedTerms, translationLoaded } from "@web/core/l10n/translation";
import { session } from "@web/session";
import { Component, markup, xml } from "@odoo/owl";
const { DateTime } = luxon;
const frenchTerms = { Hello: "Bonjour" };
class TestComponent extends Component {
static template = "";
static props = ["*"];
}
/**
* Patches the 'lang' of the user session and context.
*
* @param {string} lang
* @returns {Promise<void>}
*/
async function mockLang(lang) {
serverState.lang = lang;
await makeMockEnv();
}
test("lang is given by the user context", async () => {
onRpc("/web/webclient/translations/*", (request) => {
const urlParams = new URLSearchParams(new URL(request.url).search);
expect.step(urlParams.get("lang"));
});
await mockLang("fr_FR");
expect.verifySteps(["fr_FR"]);
});
test("lang is given by an attribute on the DOM root node", async () => {
serverState.lang = null;
onRpc("/web/webclient/translations/*", (request) => {
const urlParams = new URLSearchParams(new URL(request.url).search);
expect.step(urlParams.get("lang"));
});
document.documentElement.setAttribute("lang", "fr-FR");
after(() => {
document.documentElement.removeAttribute("lang");
});
await makeMockEnv();
expect.verifySteps(["fr_FR"]);
});
test("url is given by the session", async () => {
expect.assertions(1);
patchWithCleanup(session, {
translationURL: "/get_translations",
});
onRpc(
"/get_translations/*",
function (request) {
expect(request.url).toInclude("/get_translations/");
return this.loadTranslations();
},
{ pure: true }
);
await makeMockEnv();
});
test("can translate a text node", async () => {
TestComponent.template = xml`<div id="main">Hello</div>`;
defineParams({
translations: frenchTerms,
});
await mountWithCleanup(TestComponent);
expect("#main").toHaveText("Bonjour");
});
test("can lazy translate", async () => {
// Can't use patchWithCleanup cause it doesn't support Symbol
translatedTerms[translationLoaded] = false;
TestComponent.template = xml`<div id="main"><t t-esc="constructor.someLazyText" /></div>`;
TestComponent.someLazyText = _t("Hello");
expect(() => TestComponent.someLazyText.toString()).toThrow();
expect(() => TestComponent.someLazyText.valueOf()).toThrow();
defineParams({
translations: frenchTerms,
});
await mountWithCleanup(TestComponent);
expect("#main").toHaveText("Bonjour");
});
test("luxon is configured in the correct lang", async () => {
await mockLang("fr_BE");
expect(DateTime.utc(2021, 12, 10).toFormat("MMMM")).toBe("décembre");
});
test("arabic has the correct numbering system (generic)", async () => {
await mockLang("ar_001");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("١٠/١٢/٢٠٢١ ١٢:٠٠:٠٠");
});
test("arabic has the correct numbering system (Algeria)", async () => {
await mockLang("ar_DZ");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
});
test("arabic has the correct numbering system (Lybia)", async () => {
await mockLang("ar_LY");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
});
test("arabic has the correct numbering system (Morocco)", async () => {
await mockLang("ar_MA");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
});
test("arabic has the correct numbering system (Saudi Arabia)", async () => {
await mockLang("ar_SA");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("١٠/١٢/٢٠٢١ ١٢:٠٠:٠٠");
});
test("arabic has the correct numbering system (Tunisia)", async () => {
await mockLang("ar_TN");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
});
test("bengalese has the correct numbering system", async () => {
await mockLang("bn");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("১০/১২/২০২১ ১২::");
});
test("punjabi (gurmukhi) has the correct numbering system", async () => {
await mockLang("pa_IN");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("/੧੨/੨੦੨੧ ੧੨::");
});
test("tamil has the correct numbering system", async () => {
await mockLang("ta");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("௧௦/௧௨/௨௦௨௧ ௧௨::");
});
test("_t fills the format specifiers in translated terms with its extra arguments", async () => {
patchTranslations({
"Due in %s days": "Échéance dans %s jours",
});
const translatedStr = _t("Due in %s days", 513);
expect(translatedStr).toBe("Échéance dans 513 jours");
});
test("_t fills the format specifiers in lazy translated terms with its extra arguments", async () => {
translatedTerms[translationLoaded] = false;
const translatedStr = _t("Due in %s days", 513);
patchTranslations({
"Due in %s days": "Échéance dans %s jours",
});
expect(translatedStr.toString()).toBe("Échéance dans 513 jours");
});
describe("_t with markups", () => {
test("non-markup values are escaped", () => {
translatedTerms[translationLoaded] = true;
const maliciousUserInput = "<script>alert('This should've been escaped')</script>";
const translatedStr = _t(
"FREE %(blink_start)sROBUX%(blink_end)s, please contact %(email)s",
{
blink_start: markup("<blink>"),
blink_end: markup("</blink>"),
email: maliciousUserInput,
}
);
expect(translatedStr).toBeInstanceOf(markup().constructor);
expect(translatedStr.valueOf()).toBe(
"FREE <blink>ROBUX</blink>, please contact &lt;script&gt;alert(&#x27;This should&#x27;ve been escaped&#x27;)&lt;/script&gt;"
);
});
test("translations are escaped", () => {
translatedTerms[translationLoaded] = true;
const maliciousTranslation = "<script>document.write('pizza hawai')</script> %s";
patchTranslations({ "I love %s": maliciousTranslation });
const translatedStr = _t("I love %s", markup("<blink>Mario Kart</blink>"));
expect(translatedStr.valueOf()).toBe(
"&lt;script&gt;document.write(&#x27;pizza hawai&#x27;)&lt;/script&gt; <blink>Mario Kart</blink>"
);
});
});

View file

@ -0,0 +1,258 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { advanceTime, animationFrame, click, edit, queryOne } from "@odoo/hoot-dom";
import { Component, useState, xml } from "@odoo/owl";
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { Macro } from "@web/core/macro";
let macro;
async function waitForMacro() {
for (let i = 0; i < 50; i++) {
await animationFrame();
await advanceTime(265);
if (macro.isComplete) {
return;
}
}
if (!macro.isComplete) {
throw new Error(`Macro is not complete`);
}
}
beforeEach(() => {
patchWithCleanup(Macro.prototype, {
start() {
super.start(...arguments);
macro = this;
},
});
});
function onTextChange(element, callback) {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "characterData" || mutation.type === "childList") {
callback(element.textContent);
}
}
});
observer.observe(element, {
characterData: true,
childList: true,
subtree: true,
});
return observer;
}
class TestComponent extends Component {
static template = xml`
<div class="counter">
<p><button class="btn inc" t-on-click="() => this.state.value++">increment</button></p>
<p><button class="btn dec" t-on-click="() => this.state.value--">decrement</button></p>
<p><button class="btn double" t-on-click="() => this.state.value = 2*this.state.value">double</button></p>
<span class="value"><t t-esc="state.value"/></span>
<input />
</div>`;
static props = ["*"];
setup() {
this.state = useState({ value: 0 });
}
}
test("simple use", async () => {
await mountWithCleanup(TestComponent);
new Macro({
name: "test",
steps: [
{
trigger: "button.inc",
async action(trigger) {
await click(trigger);
},
},
],
}).start(queryOne(".counter"));
const span = queryOne("span.value");
expect(span).toHaveText("0");
onTextChange(span, expect.step);
await waitForMacro();
expect.verifySteps(["1"]);
});
test("multiple steps", async () => {
await mountWithCleanup(TestComponent);
const span = queryOne("span.value");
expect(span).toHaveText("0");
new Macro({
name: "test",
steps: [
{
trigger: "button.inc",
async action(trigger) {
await click(trigger);
},
},
{
trigger: () => (span.textContent === "1" ? span : null),
},
{
trigger: "button.inc",
async action(trigger) {
await click(trigger);
},
},
],
}).start(queryOne(".counter"));
onTextChange(span, expect.step);
await waitForMacro();
expect.verifySteps(["1", "2"]);
});
test("can input values", async () => {
await mountWithCleanup(TestComponent);
const input = queryOne("input");
new Macro({
name: "test",
steps: [
{
trigger: "div.counter input",
async action(trigger) {
await click(trigger);
await edit("aaron", { confirm: "blur" });
},
},
],
}).start(queryOne(".counter"));
expect(input).toHaveValue("");
await waitForMacro();
expect(input).toHaveValue("aaron");
});
test("a step can have no trigger", async () => {
await mountWithCleanup(TestComponent);
const input = queryOne("input");
new Macro({
name: "test",
steps: [
{ action: () => expect.step("1") },
{ action: () => expect.step("2") },
{
trigger: "div.counter input",
async action(trigger) {
await click(trigger);
await edit("aaron", { confirm: "blur" });
},
},
{ action: () => expect.step("3") },
],
}).start(queryOne(".counter"));
expect(input).toHaveValue("");
await waitForMacro();
expect(input).toHaveValue("aaron");
expect.verifySteps(["1", "2", "3"]);
});
test("onStep function is called at each step", async () => {
await mountWithCleanup(TestComponent);
const span = queryOne("span.value");
expect(span).toHaveText("0");
new Macro({
name: "test",
onStep: (el, step, index) => {
expect.step(index);
},
steps: [
{
action: () => {
console.log("brol");
},
},
{
trigger: "button.inc",
async action(trigger) {
await click(trigger);
},
},
],
}).start(queryOne(".counter"));
await waitForMacro();
expect(span).toHaveText("1");
expect.verifySteps([0, 1]);
});
test("trigger can be a function returning an htmlelement", async () => {
await mountWithCleanup(TestComponent);
const span = queryOne("span.value");
expect(span).toHaveText("0");
new Macro({
name: "test",
steps: [
{
trigger: () => queryOne("button.inc"),
async action(trigger) {
await click(trigger);
},
},
],
}).start(queryOne(".counter"));
expect(span).toHaveText("0");
await waitForMacro();
expect(span).toHaveText("1");
});
test("macro wait element is visible to do action", async () => {
await mountWithCleanup(TestComponent);
const span = queryOne("span.value");
const button = queryOne("button.inc");
button.classList.add("d-none");
expect(span).toHaveText("0");
new Macro({
name: "test",
timeout: 1000,
steps: [
{
trigger: "button.inc",
action: () => {
expect.step("element is now visible");
},
},
],
onError: (error) => {
expect.step(error);
},
}).start(queryOne(".counter"));
advanceTime(500);
button.classList.remove("d-none");
await waitForMacro();
expect.verifySteps(["element is now visible"]);
});
test("macro timeout if element is not visible", async () => {
await mountWithCleanup(TestComponent);
const span = queryOne("span.value");
const button = queryOne("button.inc");
button.classList.add("d-none");
expect(span).toHaveText("0");
const macro = new Macro({
name: "test",
timeout: 1000,
steps: [
{
trigger: "button.inc",
action: () => {
expect.step("element is now visible");
},
},
],
onError: (error) => {
expect.step(error.message);
},
});
macro.start(queryOne(".counter"));
await waitForMacro();
expect.verifySteps(["TIMEOUT step failed to complete within 1000 ms."]);
});

View file

@ -0,0 +1,167 @@
import { beforeEach, expect, onError, test } from "@odoo/hoot";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import { clearRegistry, mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { registry } from "@web/core/registry";
import { Component, onWillStart, useState, xml } from "@odoo/owl";
const mainComponentsRegistry = registry.category("main_components");
beforeEach(async () => {
clearRegistry(mainComponentsRegistry);
});
test("simple rendering", async () => {
class MainComponentA extends Component {
static template = xml`<span>MainComponentA</span>`;
static props = ["*"];
}
class MainComponentB extends Component {
static template = xml`<span>MainComponentB</span>`;
static props = ["*"];
}
mainComponentsRegistry.add("MainComponentA", { Component: MainComponentA, props: {} });
mainComponentsRegistry.add("MainComponentB", { Component: MainComponentB, props: {} });
await mountWithCleanup(MainComponentsContainer);
expect("div.o-main-components-container").toHaveCount(1);
expect(".o-main-components-container").toHaveInnerHTML(`
<span>MainComponentA</span>
<span>MainComponentB</span>
<div class="o-overlay-container"></div>
<div></div>
<div class="o_notification_manager"></div>
`);
});
test("unmounts erroring main component", async () => {
expect.assertions(6);
expect.errors(1);
onError((error) => {
expect.step(error.reason.message);
expect.step(error.reason.cause.message);
});
let compA;
class MainComponentA extends Component {
static template = xml`<span><t t-if="state.shouldThrow" t-esc="error"/>MainComponentA</span>`;
static props = ["*"];
setup() {
compA = this;
this.state = useState({ shouldThrow: false });
}
get error() {
throw new Error("BOOM");
}
}
class MainComponentB extends Component {
static template = xml`<span>MainComponentB</span>`;
static props = ["*"];
}
mainComponentsRegistry.add("MainComponentA", { Component: MainComponentA, props: {} });
mainComponentsRegistry.add("MainComponentB", { Component: MainComponentB, props: {} });
await mountWithCleanup(MainComponentsContainer);
expect("div.o-main-components-container").toHaveCount(1);
expect(".o-main-components-container").toHaveInnerHTML(`
<span>MainComponentA</span><span>MainComponentB</span>
<div class="o-overlay-container"></div>
<div></div>
<div class="o_notification_manager"></div>
`);
compA.state.shouldThrow = true;
await animationFrame();
expect.verifySteps([
'An error occured in the owl lifecycle (see this Error\'s "cause" property)',
"BOOM",
]);
expect.verifyErrors(["BOOM"]);
expect(".o-main-components-container span").toHaveCount(1);
expect(".o-main-components-container span").toHaveInnerHTML("MainComponentB");
});
test("unmounts erroring main component: variation", async () => {
expect.assertions(6);
expect.errors(1);
onError((error) => {
expect.step(error.reason.message);
expect.step(error.reason.cause.message);
});
class MainComponentA extends Component {
static template = xml`<span>MainComponentA</span>`;
static props = ["*"];
}
let compB;
class MainComponentB extends Component {
static template = xml`<span><t t-if="state.shouldThrow" t-esc="error"/>MainComponentB</span>`;
static props = ["*"];
setup() {
compB = this;
this.state = useState({ shouldThrow: false });
}
get error() {
throw new Error("BOOM");
}
}
mainComponentsRegistry.add("MainComponentA", { Component: MainComponentA, props: {} });
mainComponentsRegistry.add("MainComponentB", { Component: MainComponentB, props: {} });
await mountWithCleanup(MainComponentsContainer);
expect("div.o-main-components-container").toHaveCount(1);
expect(".o-main-components-container").toHaveInnerHTML(`
<span>MainComponentA</span><span>MainComponentB</span>
<div class="o-overlay-container"></div>
<div></div>
<div class="o_notification_manager"></div>
`);
compB.state.shouldThrow = true;
await animationFrame();
expect.verifySteps([
'An error occured in the owl lifecycle (see this Error\'s "cause" property)',
"BOOM",
]);
expect.verifyErrors(["BOOM"]);
expect(".o-main-components-container span").toHaveCount(1);
expect(".o-main-components-container span").toHaveInnerHTML("MainComponentA");
});
test("MainComponentsContainer re-renders when the registry changes", async () => {
await mountWithCleanup(MainComponentsContainer);
expect(".myMainComponent").toHaveCount(0);
class MyMainComponent extends Component {
static template = xml`<div class="myMainComponent" />`;
static props = ["*"];
}
mainComponentsRegistry.add("myMainComponent", { Component: MyMainComponent });
await animationFrame();
expect(".myMainComponent").toHaveCount(1);
});
test("Should be possible to add a new component when MainComponentContainer is not mounted yet", async () => {
const defer = new Deferred();
patchWithCleanup(MainComponentsContainer.prototype, {
setup() {
super.setup();
onWillStart(async () => {
await defer;
});
},
});
mountWithCleanup(MainComponentsContainer);
class MyMainComponent extends Component {
static template = xml`<div class="myMainComponent" />`;
static props = ["*"];
}
// Wait for the setup of MainComponentsContainer to be completed
await animationFrame();
mainComponentsRegistry.add("myMainComponent", { Component: MyMainComponent });
// Release the component mounting
defer.resolve();
await animationFrame();
expect(".myMainComponent").toHaveCount(1);
});

View file

@ -0,0 +1,921 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { Component, useState, xml } from "@odoo/owl";
import {
clickPrev,
followRelation,
getDisplayedFieldNames,
getFocusedFieldName,
getModelFieldSelectorValues,
getTitle,
openModelFieldSelectorPopover,
} from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
import {
contains,
defineModels,
fields,
models,
mountWithCleanup,
onRpc,
} from "@web/../tests/web_test_helpers";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
class Partner extends models.Model {
foo = fields.Char();
bar = fields.Boolean();
product_id = fields.Many2one({ relation: "product" });
json_field = fields.Json();
_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 },
];
}
class Product extends models.Model {
name = fields.Char({ string: "Product Name" });
_records = [
{ id: 37, name: "xphone" },
{ id: 41, name: "xpad" },
];
}
defineModels([Partner, Product]);
function addProperties() {
Partner._fields.properties = fields.Properties({
string: "Properties",
definition_record: "product_id",
definition_record_field: "definitions",
});
Product._fields.definitions = fields.PropertiesDefinition({
string: "Definitions",
});
Product._records[0].definitions = [
{ name: "xphone_prop_1", string: "P1", type: "boolean" },
{ name: "xphone_prop_2", string: "P2", type: "char" },
];
Product._records[1].definitions = [{ name: "xpad_prop_1", string: "P1", type: "date" }];
}
test("creating a field chain from scratch", async () => {
const getValueFromDOM = (root) =>
queryAllTexts(".o_model_field_selector_chain_part", { root }).join(" -> ");
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`
<ModelFieldSelector
readonly="false"
resModel="'partner'"
path="path"
isDebugMode="false"
update="(path) => this.onUpdate(path)"
/>
`;
static props = ["*"];
setup() {
this.path = "";
}
onUpdate(path) {
expect.step(`update: ${path}`);
this.path = path;
this.render();
}
}
const fieldSelector = await mountWithCleanup(Parent);
await openModelFieldSelectorPopover();
expect("input.o_input[placeholder='Search...']").toBeFocused();
expect(".o_model_field_selector_popover").toHaveCount(1);
// The field selector popover should contain the list of "partner"
// fields. "Bar" should be among them.
expect(".o_model_field_selector_popover_item_name:first").toHaveText("Bar");
// Clicking the "Bar" field should close the popover and set the field
// chain to "bar" as it is a basic field
await contains(".o_model_field_selector_popover_item_name").click();
expect(".o_model_field_selector_popover").toHaveCount(0);
expect(getValueFromDOM()).toBe("Bar");
expect(fieldSelector.path).toBe("bar");
expect.verifySteps(["update: bar"]);
await openModelFieldSelectorPopover();
expect(".o_model_field_selector_popover").toHaveCount(1);
// The field selector popover should contain the list of "partner"
// fields. "Product" should be among them.
expect(
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon"
).toHaveCount(1, { message: "field selector popover should contain the 'Product' field" });
// Clicking on the "Product" field should update the popover to show
// the product fields (so only "Product Name" and the default fields should be there)
await contains(
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon"
).click();
expect(".o_model_field_selector_popover_item_name").toHaveCount(5);
expect(queryAllTexts(".o_model_field_selector_popover_item_name").at(-1)).toBe("Product Name", {
message: "the name of the last suggestion should be 'Product Name'",
});
await contains(".o_model_field_selector_popover_item_name:last").click();
expect(".o_model_field_selector_popover").toHaveCount(0);
expect(getValueFromDOM()).toBe("Product -> Product Name");
expect.verifySteps(["update: product_id.name"]);
// Remove the current selection and recreate it again
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector_popover_prev_page").click();
await contains(".o_model_field_selector_popover_close").click();
expect.verifySteps(["update: product_id"]);
await openModelFieldSelectorPopover();
expect(
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon"
).toHaveCount(1);
await contains(
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon"
).click();
await contains(".o_model_field_selector_popover_item_name:last").click();
expect(".o_model_field_selector_popover").toHaveCount(0);
expect(getValueFromDOM()).toBe("Product -> Product Name");
expect.verifySteps(["update: product_id.name"]);
});
test("default field chain should set the page data correctly", async () => {
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "product_id",
resModel: "partner",
isDebugMode: false,
},
});
await openModelFieldSelectorPopover();
expect(".o_model_field_selector_popover").toHaveCount(1);
expect(getDisplayedFieldNames()).toEqual([
"Bar",
"Created on",
"Display name",
"Foo",
"Id",
"Last Modified on",
"Product",
]);
expect(".o_model_field_selector_popover_item:last").toHaveClass("active");
});
test("use the filter option", async () => {
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "",
resModel: "partner",
filter: (field) => field.type === "many2one" && field.searchable,
},
});
await openModelFieldSelectorPopover();
expect(getDisplayedFieldNames()).toEqual(["Product"]);
});
test("default `showSearchInput` option", async () => {
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "",
resModel: "partner",
},
});
await openModelFieldSelectorPopover();
expect(".o_model_field_selector_popover .o_model_field_selector_popover_search").toHaveCount(1);
expect(getDisplayedFieldNames()).toEqual([
"Bar",
"Created on",
"Display name",
"Foo",
"Id",
"Last Modified on",
"Product",
]);
// search 'xx'
await contains(
".o_model_field_selector_popover .o_model_field_selector_popover_search input"
).edit("xx", { confirm: false });
await runAllTimers();
expect(getDisplayedFieldNames()).toBeEmpty();
// search 'Pro'
await contains(
".o_model_field_selector_popover .o_model_field_selector_popover_search input"
).edit("Pro", { confirm: false });
await runAllTimers();
expect(getDisplayedFieldNames()).toEqual(["Product"]);
});
test("false `showSearchInput` option", async () => {
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
showSearchInput: false,
path: "",
resModel: "partner",
},
});
await openModelFieldSelectorPopover();
expect(".o_model_field_selector_popover .o_model_field_selector_popover_search").toHaveCount(0);
});
test("create a field chain with value 1 i.e. TRUE_LEAF", async () => {
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
showSearchInput: false,
path: 1,
resModel: "partner",
},
});
expect(".o_model_field_selector_chain_part").toHaveText("1");
});
test("create a field chain with value 0 i.e. FALSE_LEAF", async () => {
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
showSearchInput: false,
path: 0,
resModel: "partner",
},
});
expect(".o_model_field_selector_chain_part").toHaveText("0", {
message: "field name value should be 0.",
});
});
test("cache fields_get", async () => {
Partner._fields.partner_id = fields.Many2one({
string: "Partner",
relation: "partner",
});
onRpc("fields_get", ({ method }) => expect.step(method));
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "partner_id.partner_id.partner_id.foo",
resModel: "partner",
},
});
expect.verifySteps(["fields_get"]);
});
test("Using back button in popover", async () => {
Partner._fields.partner_id = fields.Many2one({
string: "Partner",
relation: "partner",
});
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`
<ModelFieldSelector
readonly="false"
resModel="'partner'"
path="path"
update="(path) => this.onUpdate(path)"
/>
`;
static props = ["*"];
setup() {
this.path = "partner_id.foo";
}
onUpdate(path) {
this.path = path;
this.render();
}
}
await mountWithCleanup(Parent);
expect(getModelFieldSelectorValues()).toEqual(["Partner", "Foo"]);
expect(".o_model_field_selector i.o_model_field_selector_warning").toHaveCount(0);
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector_popover_prev_page").click();
expect(getModelFieldSelectorValues()).toEqual(["Partner"]);
expect(".o_model_field_selector i.o_model_field_selector_warning").toHaveCount(0);
await contains(
".o_model_field_selector_popover_item:nth-child(1) .o_model_field_selector_popover_item_name"
).click();
expect(getModelFieldSelectorValues()).toEqual(["Bar"]);
expect(".o_model_field_selector_popover").toHaveCount(0);
});
test("select a relational field does not follow relation", async () => {
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "",
resModel: "partner",
update(path) {
expect.step(path);
},
},
});
await openModelFieldSelectorPopover();
expect(
".o_model_field_selector_popover_item:last-child .o_model_field_selector_popover_relation_icon"
).toHaveCount(1);
await contains(
".o_model_field_selector_popover_item:last-child .o_model_field_selector_popover_item_name"
).click();
expect.verifySteps(["product_id"]);
expect(".o_popover").toHaveCount(0);
await openModelFieldSelectorPopover();
expect(getDisplayedFieldNames()).toEqual([
"Bar",
"Created on",
"Display name",
"Foo",
"Id",
"Last Modified on",
"Product",
]);
expect(".o_model_field_selector_popover_relation_icon").toHaveCount(1);
await contains(".o_model_field_selector_popover_relation_icon").click();
expect(getDisplayedFieldNames()).toEqual([
"Created on",
"Display name",
"Id",
"Last Modified on",
"Product Name",
]);
expect(".o_popover").toHaveCount(1);
await contains(".o_model_field_selector_popover_item_name").click();
expect.verifySteps(["product_id.create_date"]);
expect(".o_popover").toHaveCount(0);
});
test("can follow relations", async () => {
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "",
resModel: "partner",
followRelations: true, // default
update(path) {
expect(path).toBe("product_id");
},
},
});
await openModelFieldSelectorPopover();
expect(getDisplayedFieldNames()).toEqual([
"Bar",
"Created on",
"Display name",
"Foo",
"Id",
"Last Modified on",
"Product",
]);
expect(".o_model_field_selector_popover_relation_icon").toHaveCount(1);
await contains(".o_model_field_selector_popover_relation_icon").click();
expect(getDisplayedFieldNames()).toEqual([
"Created on",
"Display name",
"Id",
"Last Modified on",
"Product Name",
]);
expect(".o_popover").toHaveCount(1);
});
test("cannot follow relations", async () => {
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "",
resModel: "partner",
followRelations: false,
update(path) {
expect(path).toBe("product_id");
},
},
});
await openModelFieldSelectorPopover();
expect(getDisplayedFieldNames()).toEqual([
"Bar",
"Created on",
"Display name",
"Foo",
"Id",
"Last Modified on",
"Product",
]);
expect(".o_model_field_selector_popover_relation_icon").toHaveCount(0);
await contains(".o_model_field_selector_popover_item_name:last").click();
expect(".o_popover").toHaveCount(0);
expect(getModelFieldSelectorValues()).toEqual(["Product"]);
});
test("Edit path in popover debug input", async () => {
Partner._fields.partner_id = fields.Many2one({
string: "Partner",
relation: "partner",
});
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`
<ModelFieldSelector
readonly="false"
resModel="'partner'"
path="path"
isDebugMode="true"
update="(pathInfo) => this.onUpdate(pathInfo)"
/>
`;
static props = ["*"];
setup() {
this.path = "foo";
}
onUpdate(path) {
this.path = path;
this.render();
}
}
await mountWithCleanup(Parent);
expect(getModelFieldSelectorValues()).toEqual(["Foo"]);
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector_popover .o_model_field_selector_debug").edit(
"partner_id.bar"
);
expect(getModelFieldSelectorValues()).toEqual(["Partner", "Bar"]);
});
test("title on first four pages", async () => {
class Turtle extends models.Model {
mother_id = fields.Many2one({
string: "Mother",
relation: "turtle",
});
}
defineModels([Turtle]);
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "mother_id",
resModel: "turtle",
},
});
await openModelFieldSelectorPopover();
expect(getTitle()).toBe("Select a field");
await followRelation();
expect(getTitle()).toBe("Mother");
await followRelation();
expect(getTitle()).toBe("... > Mother");
await followRelation();
expect(getTitle()).toBe("... > Mother");
});
test("start on complex path and click prev", async () => {
class Turtle extends models.Model {
mother_id = fields.Many2one({
string: "Mother",
relation: "turtle",
});
father_id = fields.Many2one({
string: "Father",
relation: "turtle",
});
}
defineModels([Turtle]);
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "mother_id.father_id.mother_id",
resModel: "turtle",
},
});
await openModelFieldSelectorPopover();
// viewing third page
// mother is selected on that page
expect(getTitle()).toBe("... > Father");
expect(getFocusedFieldName()).toBe("Mother");
expect(getModelFieldSelectorValues()).toEqual(["Mother", "Father", "Mother"]);
// select Father on third page and go to next page
// no selection on fourth page --> first item is focused
await followRelation();
expect(getTitle()).toBe("... > Father");
expect(getFocusedFieldName()).toBe("Created on");
expect(getModelFieldSelectorValues()).toEqual(["Mother", "Father", "Father"]);
// go back to third page. Nothing has changed
await clickPrev();
expect(getTitle()).toBe("... > Father");
expect(getFocusedFieldName()).toBe("Father");
expect(getModelFieldSelectorValues()).toEqual(["Mother", "Father", "Father"]);
// go back to second page. Nothing has changed.
await clickPrev();
expect(getTitle()).toBe("Mother");
expect(getFocusedFieldName()).toBe("Father");
expect(getModelFieldSelectorValues()).toEqual(["Mother", "Father"]);
// go back to first page. Nothing has changed.
await clickPrev();
expect(getTitle()).toBe("Select a field");
expect(getFocusedFieldName()).toBe("Mother");
expect(getModelFieldSelectorValues()).toEqual(["Mother"]);
expect(".o_model_field_selector_popover_prev_page").toHaveCount(0);
});
test("support of invalid paths (allowEmpty=false)", async () => {
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" path="state.path" />`;
static props = ["*"];
setup() {
this.state = useState({ path: `` });
}
}
const parent = await mountWithCleanup(Parent);
expect(getModelFieldSelectorValues()).toEqual(["-"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
parent.state.path = undefined;
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual(["-"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
parent.state.path = false;
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual(["-"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
parent.state.path = {};
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual(["-"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
parent.state.path = `a`;
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual(["a"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
parent.state.path = `foo.a`;
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual(["Foo", "a"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
parent.state.path = `a.foo`;
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual(["a", "foo"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
});
test("support of invalid paths (allowEmpty=true)", async () => {
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" path="state.path" allowEmpty="true" />`;
static props = ["*"];
setup() {
this.state = useState({ path: `` });
}
}
const parent = await mountWithCleanup(Parent);
expect(getModelFieldSelectorValues()).toEqual([]);
expect(".o_model_field_selector_warning").toHaveCount(0);
parent.state.path = undefined;
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual([]);
expect(".o_model_field_selector_warning").toHaveCount(0);
parent.state.path = false;
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual([]);
expect(".o_model_field_selector_warning").toHaveCount(0);
parent.state.path = {};
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual(["-"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
parent.state.path = `a`;
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual(["a"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
parent.state.path = `foo.a`;
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual(["Foo", "a"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
parent.state.path = `a.foo`;
await animationFrame();
expect(getModelFieldSelectorValues()).toEqual(["a", "foo"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
});
test("debug input", async () => {
expect.assertions(10);
let num = 1;
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" isDebugMode="true" path="state.path" update.bind="update"/>`;
static props = ["*"];
setup() {
this.state = useState({ path: `` });
}
update(path, fieldInfo) {
if (num === 1) {
expect(path).toBe("a");
expect(fieldInfo).toEqual({
fieldDef: null,
resModel: "partner",
});
num++;
} else {
expect(path).toBe("foo");
expect(fieldInfo).toEqual({
resModel: "partner",
fieldDef: {
string: "Foo",
readonly: false,
required: false,
searchable: true,
sortable: true,
store: true,
groupable: true,
type: "char",
name: "foo",
},
});
}
}
}
await mountWithCleanup(Parent);
expect(getModelFieldSelectorValues()).toEqual(["-"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector_debug").edit("a", { confirm: false });
await contains(".o_model_field_selector_popover_search").click();
expect(getModelFieldSelectorValues()).toEqual(["a"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
await contains(".o_model_field_selector_popover_close").click();
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector_debug").edit("foo");
expect(getModelFieldSelectorValues()).toEqual(["Foo"]);
expect(".o_model_field_selector_warning").toHaveCount(0);
await contains(".o_model_field_selector_popover_close").click();
});
test("focus on search input", async () => {
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" path="state.path" update.bind="update"/>`;
static props = ["*"];
setup() {
this.state = useState({ path: `foo` });
}
update() {}
}
await mountWithCleanup(Parent);
await openModelFieldSelectorPopover();
expect(".o_model_field_selector_popover_search .o_input").toBeFocused();
await followRelation();
expect(".o_model_field_selector_popover_search .o_input").toBeFocused();
});
test("support properties", async () => {
addProperties();
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`
<ModelFieldSelector
readonly="false"
resModel="'partner'"
path="path"
isDebugMode="true"
update="(path, fieldInfo) => this.onUpdate(path)"
/>
`;
static props = ["*"];
setup() {
this.path = "foo";
}
onUpdate(path) {
this.path = path;
expect.step(path);
this.render();
}
}
await mountWithCleanup(Parent);
await openModelFieldSelectorPopover();
expect(getTitle()).toBe("Select a field");
expect('.o_model_field_selector_popover_item[data-name="properties"]').toHaveCount(1);
expect(
'.o_model_field_selector_popover_item[data-name="properties"] .o_model_field_selector_popover_relation_icon'
).toHaveCount(1);
expect(getModelFieldSelectorValues()).toEqual(["Foo"]);
expect(".o_model_field_selector_warning").toHaveCount(0);
await contains(
'.o_model_field_selector_popover_item[data-name="properties"] .o_model_field_selector_popover_relation_icon'
).click();
expect(getModelFieldSelectorValues()).toEqual(["Properties"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
expect.verifySteps([]);
await clickPrev();
expect(getTitle()).toBe("Select a field");
await contains(
'.o_model_field_selector_popover_item[data-name="properties"] .o_model_field_selector_popover_item_name'
).click();
expect(getTitle()).toBe("Properties");
expect(".o_model_field_selector_value").toHaveText("Properties");
expect(".o_model_field_selector_popover_item").toHaveCount(3);
expect('.o_model_field_selector_popover_item[data-name="xphone_prop_1"]').toHaveCount(1);
expect('.o_model_field_selector_popover_item[data-name="xphone_prop_2"]').toHaveCount(1);
expect('.o_model_field_selector_popover_item[data-name="xpad_prop_1"]').toHaveCount(1);
expect(getDisplayedFieldNames()).toEqual([
"P1 (xphone)\nxphone_prop_1 (boolean)",
"P1 (xpad)\nxpad_prop_1 (date)",
"P2 (xphone)\nxphone_prop_2 (char)",
]);
await contains(
'.o_model_field_selector_popover_item[data-name="xphone_prop_2"] .o_model_field_selector_popover_item_name'
).click();
expect.verifySteps(["properties.xphone_prop_2"]);
expect(".o_model_field_selector_value").toHaveText("PropertiesP2");
expect(".o_model_field_selector_warning").toHaveCount(0);
});
test("search on field string and name in debug mode", async () => {
Partner._fields.ucit = fields.Char({
type: "char",
string: "Some string",
});
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`
<ModelFieldSelector
readonly="false"
resModel="'partner'"
path="'foo'"
isDebugMode="true"
/>
`;
static props = ["*"];
}
await mountWithCleanup(Parent);
await openModelFieldSelectorPopover();
await contains(
".o_model_field_selector_popover .o_model_field_selector_popover_search input"
).edit("uct", { confirm: false });
await runAllTimers();
expect(getDisplayedFieldNames()).toEqual([
"Product\nproduct_id (many2one)",
"Some string\nucit (char)",
]);
});
test("clear button (allowEmpty=true)", async () => {
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`
<ModelFieldSelector
readonly="false"
resModel="'partner'"
path="path"
allowEmpty="true"
isDebugMode="true"
update="(path, fieldInfo) => this.onUpdate(path)"
/>
`;
static props = ["*"];
setup() {
this.path = "baaarrr";
}
onUpdate(path) {
this.path = path;
expect.step(`path is ${JSON.stringify(path)}`);
this.render();
}
}
await mountWithCleanup(Parent);
expect(getModelFieldSelectorValues()).toEqual(["baaarrr"]);
expect(".o_model_field_selector_warning").toHaveCount(1);
expect(".o_model_field_selector .fa.fa-times").toHaveCount(1);
// clear when popover is not open
await contains(".o_model_field_selector .fa.fa-times").click();
expect(getModelFieldSelectorValues()).toEqual([]);
expect(".o_model_field_selector_warning").toHaveCount(0);
expect(".o_model_field_selector .fa.fa-times").toHaveCount(0);
expect.verifySteps([`path is ""`]);
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector_popover_item_name").click();
expect(getModelFieldSelectorValues()).toEqual(["Bar"]);
expect(".o_model_field_selector_warning").toHaveCount(0);
expect(".o_model_field_selector .fa.fa-times").toHaveCount(1);
expect.verifySteps([`path is "bar"`]);
// clear when popover is open
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector .fa.fa-times").click();
expect(getModelFieldSelectorValues()).toEqual([]);
expect(".o_model_field_selector_warning").toHaveCount(0);
expect(".o_model_field_selector .fa.fa-times").toHaveCount(0);
expect.verifySteps([`path is ""`]);
});
test("Modify path in popover debug input and click away", async () => {
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`
<ModelFieldSelector
readonly="false"
resModel="'partner'"
path="path"
isDebugMode="true"
update.bind="update"
/>
`;
static props = ["*"];
setup() {
this.path = "foo";
}
update(path) {
this.path = path;
expect.step(path);
this.render();
}
}
await mountWithCleanup(Parent);
expect(getModelFieldSelectorValues()).toEqual(["Foo"]);
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector_popover .o_model_field_selector_debug").edit(
"foooooo",
{ confirm: false }
);
expect(getModelFieldSelectorValues()).toEqual(["Foo"]);
await contains(getFixture()).click();
expect(getModelFieldSelectorValues()).toEqual(["foooooo"]);
expect.verifySteps(["foooooo"]);
});
test("showDebugInput = false", async () => {
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "product_id",
resModel: "partner",
isDebugMode: true,
showDebugInput: false,
},
});
await openModelFieldSelectorPopover();
expect(".o_model_field_selector_debug").toHaveCount(0);
});

View file

@ -0,0 +1,172 @@
import { describe, expect, test } from "@odoo/hoot";
import { queryAll } from "@odoo/hoot-dom";
import { runAllTimers } from "@odoo/hoot-mock";
import {
contains,
defineModels,
fields,
models,
mountWithCleanup,
onRpc,
} from "@web/../tests/web_test_helpers";
import { ModelSelector } from "@web/core/model_selector/model_selector";
async function mountModelSelector(models = [], value = undefined, onModelSelected = () => {}) {
await mountWithCleanup(ModelSelector, {
props: {
models,
value,
onModelSelected,
},
});
}
class IrModel extends models.Model {
_name = "ir.model";
name = fields.Char({ string: "Model Name" });
model = fields.Char();
_records = [
{ id: 1, name: "Model 1", model: "model_1" },
{ id: 2, name: "Model 2", model: "model_2" },
{ id: 3, name: "Model 3", model: "model_3" },
{ id: 4, name: "Model 4", model: "model_4" },
{ id: 5, name: "Model 5", model: "model_5" },
{ id: 6, name: "Model 6", model: "model_6" },
{ id: 7, name: "Model 7", model: "model_7" },
{ id: 8, name: "Model 8", model: "model_8" },
{ id: 9, name: "Model 9", model: "model_9" },
{ id: 10, name: "Model 10", model: "model_10" },
];
}
defineModels([IrModel]);
describe.current.tags("desktop");
onRpc("ir.model", "display_name_for", function ({ args }) {
const models = args[0];
const records = this.env["ir.model"].filter((rec) => models.includes(rec.model));
return records.map((record) => ({
model: record.model,
display_name: record.name,
}));
});
test("model_selector: with no model", async () => {
await mountModelSelector();
await contains(".o-autocomplete--input").click();
expect("li.o-autocomplete--dropdown-item").toHaveCount(1);
expect("li.o-autocomplete--dropdown-item").toHaveText("No records");
});
test("model_selector: displays model display names", async () => {
await mountModelSelector(["model_1", "model_2", "model_3"]);
await contains(".o-autocomplete--input").click();
expect("li.o-autocomplete--dropdown-item").toHaveCount(3);
const items = queryAll("li.o-autocomplete--dropdown-item");
expect(items[0]).toHaveText("Model 1");
expect(items[1]).toHaveText("Model 2");
expect(items[2]).toHaveText("Model 3");
});
test("model_selector: with 8 models", async () => {
await mountModelSelector([
"model_1",
"model_2",
"model_3",
"model_4",
"model_5",
"model_6",
"model_7",
"model_8",
]);
await contains(".o-autocomplete--input").click();
expect("li.o-autocomplete--dropdown-item").toHaveCount(8);
});
test("model_selector: with more than 8 models", async () => {
await mountModelSelector([
"model_1",
"model_2",
"model_3",
"model_4",
"model_5",
"model_6",
"model_7",
"model_8",
"model_9",
"model_10",
]);
await contains(".o-autocomplete--input").click();
expect("li.o-autocomplete--dropdown-item").toHaveCount(9);
expect("li.o-autocomplete--dropdown-item:eq(8)").toHaveText("Start typing...");
});
test("model_selector: search content is not applied when opening the autocomplete", async () => {
await mountModelSelector(["model_1", "model_2"], "_2");
await contains(".o-autocomplete--input").click();
expect("li.o-autocomplete--dropdown-item").toHaveCount(2);
});
test("model_selector: with search matching some records on technical name", async () => {
await mountModelSelector(["model_1", "model_2"]);
await contains(".o-autocomplete--input").click();
await contains(".o-autocomplete--input").edit("_2", { confirm: false });
await runAllTimers();
expect("li.o-autocomplete--dropdown-item").toHaveCount(1);
expect("li.o-autocomplete--dropdown-item").toHaveText("Model 2");
});
test("model_selector: with search matching some records on business name", async () => {
await mountModelSelector(["model_1", "model_2"]);
await contains(".o-autocomplete--input").click();
await contains(".o-autocomplete--input").edit(" 2", { confirm: false });
await runAllTimers();
expect("li.o-autocomplete--dropdown-item").toHaveCount(1);
expect("li.o-autocomplete--dropdown-item").toHaveText("Model 2");
});
test("model_selector: with search matching no record", async () => {
await mountModelSelector(["model_1", "model_2"]);
await contains(".o-autocomplete--input").edit("a random search query", { confirm: false });
await runAllTimers();
expect("li.o-autocomplete--dropdown-item").toHaveCount(1);
expect("li.o-autocomplete--dropdown-item").toHaveText("No records");
});
test("model_selector: select a model", async () => {
await mountModelSelector(["model_1", "model_2", "model_3"], "Model 1", (selected) => {
expect.step("model selected");
expect(selected).toEqual({
label: "Model 2",
technical: "model_2",
});
});
await contains(".o-autocomplete--input").click();
await contains(".o_model_selector_model_2").click();
expect.verifySteps(["model selected"]);
});
test("model_selector: click on start typing", async () => {
await mountModelSelector([
"model_1",
"model_2",
"model_3",
"model_4",
"model_5",
"model_6",
"model_7",
"model_8",
"model_9",
"model_10",
]);
await contains(".o-autocomplete--input").click();
await contains("li.o-autocomplete--dropdown-item:eq(8)").click();
expect(".o-autocomplete--input").toHaveValue("");
expect(".o-autocomplete.dropdown ul").toHaveCount(0);
});
test("model_selector: with an initial value", async () => {
await mountModelSelector(["model_1", "model_2", "model_3"], "Model 1");
expect(".o-autocomplete--input").toHaveValue("Model 1");
});

View file

@ -0,0 +1,114 @@
import { expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { contains, mountWithCleanup, onRpc } from "@web/../tests/web_test_helpers";
import { NameAndSignature } from "@web/core/signature/name_and_signature";
const getNameAndSignatureButtonNames = () => {
return queryAllTexts(".card-header .col-auto").filter((text) => text.length);
};
onRpc("/web/sign/get_fonts/", () => {
return {};
});
test("test name_and_signature widget", async () => {
const props = {
signature: {
name: "Don Toliver",
},
};
await mountWithCleanup(NameAndSignature, { props });
expect(getNameAndSignatureButtonNames()).toEqual(["Auto", "Draw", "Load"]);
expect(".o_web_sign_auto_select_style").toHaveCount(1);
expect(".card-header .active").toHaveCount(1);
expect(".card-header .active").toHaveText("Auto");
expect(".o_web_sign_name_group input").toHaveCount(1);
expect(".o_web_sign_name_group input").toHaveValue("Don Toliver");
await contains(".o_web_sign_draw_button").click();
expect(getNameAndSignatureButtonNames()).toEqual(["Auto", "Draw", "Load"]);
expect(".o_web_sign_draw_clear").toHaveCount(1);
expect(".card-header .active").toHaveCount(1);
expect(".card-header .active").toHaveText("Draw");
await contains(".o_web_sign_load_button").click();
expect(getNameAndSignatureButtonNames()).toEqual(["Auto", "Draw", "Load"]);
expect(".o_web_sign_load_file").toHaveCount(1);
expect(".card-header .active").toHaveCount(1);
expect(".card-header .active").toHaveText("Load");
});
test("test name_and_signature widget without name", async () => {
await mountWithCleanup(NameAndSignature, { props: { signature: {} } });
expect(".card-header").toHaveCount(0);
expect(".o_web_sign_name_group input").toHaveCount(1);
expect(".o_web_sign_name_group input").toHaveValue("");
await contains(".o_web_sign_name_group input").fill("plop", { instantly: true });
expect(getNameAndSignatureButtonNames()).toEqual(["Auto", "Draw", "Load"]);
expect(".o_web_sign_auto_select_style").toHaveCount(1);
expect(".card-header .active").toHaveText("Auto");
expect(".o_web_sign_name_group input").toHaveCount(1);
expect(".o_web_sign_name_group input").toHaveValue("plop");
await contains(".o_web_sign_draw_button").click();
expect(".card-header .active").toHaveCount(1);
expect(".card-header .active").toHaveText("Draw");
});
test("test name_and_signature widget with noInputName and default name", async function () {
const props = {
signature: {
name: "Don Toliver",
},
noInputName: true,
};
await mountWithCleanup(NameAndSignature, { props });
expect(getNameAndSignatureButtonNames()).toEqual(["Auto", "Draw", "Load"]);
expect(".o_web_sign_auto_select_style").toHaveCount(1);
expect(".card-header .active").toHaveCount(1);
expect(".card-header .active").toHaveText("Auto");
});
test("test name_and_signature widget with noInputName and without name", async function () {
const props = {
signature: {},
noInputName: true,
};
await mountWithCleanup(NameAndSignature, { props });
expect(getNameAndSignatureButtonNames()).toEqual(["Draw", "Load"]);
expect(".o_web_sign_draw_clear").toHaveCount(1);
expect(".card-header .active").toHaveCount(1);
expect(".card-header .active").toHaveText("Draw");
});
test("test name_and_signature widget default signature", async function () {
const props = {
signature: {
name: "Brandon Freeman",
signatureImage:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+BCQAHBQICJmhD1AAAAABJRU5ErkJggg==",
},
mode: "draw",
signatureType: "signature",
noInputName: true,
};
const res = await mountWithCleanup(NameAndSignature, { props });
expect(res.isSignatureEmpty).toBe(false);
expect(res.props.signature.isSignatureEmpty).toBe(false);
});
test("test name_and_signature widget update signmode with onSignatureChange prop", async function () {
let currentSignMode = "";
const props = {
signature: { name: "Test Owner" },
onSignatureChange: function (signMode) {
if (currentSignMode !== signMode) {
currentSignMode = signMode;
}
},
};
await mountWithCleanup(NameAndSignature, { props });
await contains(".o_web_sign_draw_button").click();
expect(currentSignMode).toBe("draw");
});

View file

@ -0,0 +1,146 @@
import { after, describe, expect, test } from "@odoo/hoot";
import {
defineModels,
getService,
makeMockEnv,
models,
onRpc,
} from "@web/../tests/web_test_helpers";
import { ERROR_INACCESSIBLE_OR_MISSING } from "@web/core/name_service";
import { rpcBus } from "@web/core/network/rpc";
class Dev extends models.Model {
_name = "dev";
_rec_name = "display_name";
_records = [
{ id: 1, display_name: "Julien" },
{ id: 2, display_name: "Pierre" },
];
}
class PO extends models.Model {
_name = "po";
_rec_name = "display_name";
_records = [{ id: 1, display_name: "Damien" }];
}
defineModels([Dev, PO]);
describe.current.tags("headless");
test("single loadDisplayNames", async () => {
await makeMockEnv();
const displayNames = await getService("name").loadDisplayNames("dev", [1, 2]);
expect(displayNames).toEqual({ 1: "Julien", 2: "Pierre" });
});
test("loadDisplayNames is done in silent mode", async () => {
await makeMockEnv();
const onRPCRequest = ({ detail }) => {
const silent = detail.settings.silent ? "(silent)" : "";
expect.step(`RPC:REQUEST${silent}`);
};
rpcBus.addEventListener("RPC:REQUEST", onRPCRequest);
after(() => rpcBus.removeEventListener("RPC:REQUEST", onRPCRequest));
await getService("name").loadDisplayNames("dev", [1]);
expect.verifySteps(["RPC:REQUEST(silent)"]);
});
test("single loadDisplayNames following addDisplayNames", async () => {
await makeMockEnv();
onRpc(({ model, method, kwargs }) => {
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
});
getService("name").addDisplayNames("dev", { 1: "JUM", 2: "PIPU" });
const displayNames = await getService("name").loadDisplayNames("dev", [1, 2]);
expect(displayNames).toEqual({ 1: "JUM", 2: "PIPU" });
expect.verifySteps([]);
});
test("single loadDisplayNames following addDisplayNames (2)", async () => {
await makeMockEnv();
onRpc(({ model, method, kwargs }) => {
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
});
getService("name").addDisplayNames("dev", { 1: "JUM" });
const displayNames = await getService("name").loadDisplayNames("dev", [1, 2]);
expect(displayNames).toEqual({ 1: "JUM", 2: "Pierre" });
expect.verifySteps(["dev:web_search_read:2"]);
});
test("loadDisplayNames in batch", async () => {
await makeMockEnv();
onRpc(({ model, method, kwargs }) => {
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
});
const loadPromise1 = getService("name").loadDisplayNames("dev", [1]);
expect.verifySteps([]);
const loadPromise2 = getService("name").loadDisplayNames("dev", [2]);
expect.verifySteps([]);
const [displayNames1, displayNames2] = await Promise.all([loadPromise1, loadPromise2]);
expect(displayNames1).toEqual({ 1: "Julien" });
expect(displayNames2).toEqual({ 2: "Pierre" });
expect.verifySteps(["dev:web_search_read:1,2"]);
});
test("loadDisplayNames on different models", async () => {
await makeMockEnv();
onRpc(({ model, method, kwargs }) => {
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
});
const loadPromise1 = getService("name").loadDisplayNames("dev", [1]);
expect.verifySteps([]);
const loadPromise2 = getService("name").loadDisplayNames("po", [1]);
expect.verifySteps([]);
const [displayNames1, displayNames2] = await Promise.all([loadPromise1, loadPromise2]);
expect(displayNames1).toEqual({ 1: "Julien" });
expect(displayNames2).toEqual({ 1: "Damien" });
expect.verifySteps(["dev:web_search_read:1", "po:web_search_read:1"]);
});
test("invalid id", async () => {
await makeMockEnv();
try {
await getService("name").loadDisplayNames("dev", ["a"]);
} catch (error) {
expect(error.message).toBe("Invalid ID: a");
}
});
test("inaccessible or missing id", async () => {
await makeMockEnv();
onRpc(({ model, method, kwargs }) => {
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
});
const displayNames = await getService("name").loadDisplayNames("dev", [3]);
expect(displayNames).toEqual({ 3: ERROR_INACCESSIBLE_OR_MISSING });
expect.verifySteps(["dev:web_search_read:3"]);
});
test("batch + inaccessible/missing", async () => {
await makeMockEnv();
onRpc(({ model, method, kwargs }) => {
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
});
const loadPromise1 = getService("name").loadDisplayNames("dev", [1, 3]);
expect.verifySteps([]);
const loadPromise2 = getService("name").loadDisplayNames("dev", [2, 4]);
expect.verifySteps([]);
const [displayNames1, displayNames2] = await Promise.all([loadPromise1, loadPromise2]);
expect(displayNames1).toEqual({ 1: "Julien", 3: ERROR_INACCESSIBLE_OR_MISSING });
expect(displayNames2).toEqual({ 2: "Pierre", 4: ERROR_INACCESSIBLE_OR_MISSING });
expect.verifySteps(["dev:web_search_read:1,3,2,4"]);
});

View file

@ -0,0 +1,189 @@
import { Component, xml } from "@odoo/owl";
import { useNavigation } from "@web/core/navigation/navigation";
import { useAutofocus } from "@web/core/utils/hooks";
import { describe, expect, test } from "@odoo/hoot";
import { hover, press } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
class BasicHookParent extends Component {
static props = [];
static template = xml`
<button class="outside" t-ref="outsideRef">outside target</button>
<div class="container" t-ref="containerRef">
<button class="o-navigable one" t-on-click="() => this.onClick(1)">target one</button>
<div class="o-navigable two" tabindex="0" t-on-click="() => this.onClick(2)">target two</div>
<input class="o-navigable three" t-on-click="() => this.onClick(3)"/><br/>
<button class="no-nav-class">skipped</button><br/>
<a class="o-navigable four" tabindex="0" t-on-click="() => this.onClick(4)">target four</a>
<div class="o-navigable five">
<button t-on-click="() => this.onClick(5)">target five</button>
</div>
</div>
`;
setup() {
useAutofocus({ refName: "outsideRef" });
this.navigation = useNavigation("containerRef", this.navOptions);
}
navOptions = {};
onClick(id) {}
}
describe.current.tags("desktop");
test("default navigation", async () => {
async function navigate(hotkey, focused) {
await press(hotkey);
await animationFrame();
expect(focused).toBeFocused();
expect(focused).toHaveClass("focus");
}
class Parent extends BasicHookParent {
onClick(id) {
expect.step(id);
}
}
await mountWithCleanup(Parent);
expect(".one").toBeFocused();
await navigate("arrowdown", ".two");
await navigate("arrowdown", ".three");
await navigate("arrowdown", ".four");
await navigate("arrowdown", ".five button");
await navigate("arrowdown", ".one");
await navigate("arrowup", ".five button");
await navigate("arrowup", ".four");
await navigate("end", ".five button");
await navigate("home", ".one");
await navigate("tab", ".two");
await navigate("shift+tab", ".one");
await navigate("arrowleft", ".one");
await navigate("arrowright", ".one");
await navigate("space", ".one");
await navigate("escape", ".one");
await press("enter");
await animationFrame();
expect.verifySteps([1]);
await navigate("arrowdown", ".two");
await press("enter");
await animationFrame();
expect.verifySteps([2]);
});
test("hotkey override options", async () => {
class Parent extends BasicHookParent {
navOptions = {
hotkeys: {
arrowleft: (index, items) => {
expect.step(index);
items[(index + 2) % items.length].focus();
},
escape: (index, items) => {
expect.step("escape");
items[0].focus();
},
},
};
onClick(id) {
expect.step(id);
}
}
await mountWithCleanup(Parent);
expect(".one").toBeFocused();
await press("arrowleft");
await animationFrame();
expect(".three").toBeFocused();
expect.verifySteps([0]);
await press("escape");
await animationFrame();
expect(".one").toBeFocused();
expect.verifySteps(["escape"]);
});
test("navigation with virtual focus", async () => {
async function navigate(hotkey, expected) {
await press(hotkey);
await animationFrame();
// Focus is kept on button outside container
expect(".outside").toBeFocused();
// Virtually focused element has "focus" class
expect(expected).toHaveClass("focus");
}
class Parent extends BasicHookParent {
navOptions = {
virtualFocus: true,
};
onClick(id) {
expect.step(id);
}
}
await mountWithCleanup(Parent);
expect(".one").toHaveClass("focus");
await navigate("arrowdown", ".two");
await navigate("arrowdown", ".three");
await navigate("arrowdown", ".four");
await navigate("arrowdown", ".five button");
await navigate("arrowdown", ".one");
await navigate("arrowup", ".five button");
await navigate("arrowup", ".four");
await navigate("end", ".five button");
await navigate("home", ".one");
await navigate("tab", ".two");
await navigate("shift+tab", ".one");
await press("enter");
await animationFrame();
expect.verifySteps([1]);
await navigate("arrowdown", ".two");
await press("enter");
await animationFrame();
expect.verifySteps([2]);
});
test("hovering an item makes it active but doesn't focus", async () => {
await mountWithCleanup(BasicHookParent);
await press("arrowdown");
expect(".two").toBeFocused();
expect(".two").toHaveClass("focus");
hover(".three");
await animationFrame();
expect(".two").toBeFocused();
expect(".two").not.toHaveClass("focus");
expect(".three").not.toBeFocused();
expect(".three").toHaveClass("focus");
press("arrowdown");
await animationFrame();
expect(".four").toBeFocused();
expect(".four").toHaveClass("focus");
});

View file

@ -0,0 +1,107 @@
import { after, describe, expect, test } from "@odoo/hoot";
import { Deferred, mockFetch } from "@odoo/hoot-mock";
import { patchTranslations } from "@web/../tests/web_test_helpers";
import { download } from "@web/core/network/download";
import { ConnectionLostError, RPCError } from "@web/core/network/rpc";
describe.current.tags("headless");
test("handles connection error when behind a server", async () => {
mockFetch(() => new Response("", { status: 502 }));
const error = new ConnectionLostError("/some_url");
await expect(download({ data: {}, url: "/some_url" })).rejects.toThrow(error);
});
test("handles connection error when network unavailable", async () => {
mockFetch(() => Promise.reject());
const error = new ConnectionLostError("/some_url");
await expect(download({ data: {}, url: "/some_url" })).rejects.toThrow(error);
});
test("handles business error from server", async () => {
const serverError = {
code: 200,
data: {
name: "odoo.exceptions.RedirectWarning",
arguments: ["Business Error Message", "someArg"],
message: "Business Error Message",
},
message: "Odoo Server Error",
};
mockFetch(() => new Blob([JSON.stringify(serverError)], { type: "text/html" }));
let error = null;
try {
await download({
data: {},
url: "/some_url",
});
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(RPCError);
expect(error.data).toEqual(serverError.data);
});
test("handles arbitrary error", async () => {
const serverError = /* xml */ `<html><body><div>HTML error message</div></body></html>`;
mockFetch(() => new Blob([JSON.stringify(serverError)], { type: "text/html" }));
let error = null;
try {
await download({
data: {},
url: "/some_url",
});
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(RPCError);
expect(error.message).toBe("Arbitrary Uncaught Python Exception");
expect(error.data.debug.trim()).toBe("200\nHTML error message");
});
test("handles success download", async () => {
patchTranslations();
// This test relies on a implementation detail of the lowest layer of download
// That is, a link will be created with the download attribute
mockFetch((_, { body }) => {
expect(body).toBeInstanceOf(FormData);
expect(body.get("someKey")).toBe("someValue");
expect(body.has("token")).toBe(true);
expect(body.has("csrf_token")).toBe(true);
expect.step("fetching file");
return new Blob(["some plain text file"], { type: "text/plain" });
});
const deferred = new Deferred();
// This part asserts the implementation detail in question
const downloadOnClick = (ev) => {
const target = ev.target;
if (target.tagName === "A" && "download" in target.attributes) {
ev.preventDefault();
expect(target.href).toMatch(/^blob:/);
expect.step("file downloaded");
document.removeEventListener("click", downloadOnClick);
deferred.resolve();
}
};
document.addEventListener("click", downloadOnClick);
after(() => document.removeEventListener("click", downloadOnClick));
expect("a[download]").toHaveCount(0); // link will be added by download
download({ data: { someKey: "someValue" }, url: "/some_url" });
await deferred;
expect.verifySteps(["fetching file", "file downloaded"]);
});

View file

@ -0,0 +1,40 @@
import { describe, expect, test } from "@odoo/hoot";
import { mockFetch } from "@odoo/hoot-mock";
import { get, post } from "@web/core/network/http_service";
describe.current.tags("headless");
test("method is correctly set", async () => {
mockFetch((_, { method }) => expect.step(method));
await get("/call_get");
expect.verifySteps(["GET"]);
await post("/call_post");
expect.verifySteps(["POST"]);
});
test("check status 502", async () => {
mockFetch(() => new Response("{}", { status: 502 }));
await expect(get("/custom_route")).rejects.toThrow(/Failed to fetch/);
});
test("FormData is built by post", async () => {
mockFetch((_, { body }) => {
expect(body).toBeInstanceOf(FormData);
expect(body.get("s")).toBe("1");
expect(body.get("a")).toBe("1");
expect(body.getAll("a")).toEqual(["1", "2", "3"]);
});
await post("call_post", { s: 1, a: [1, 2, 3] });
});
test("FormData is given to post", async () => {
const formData = new FormData();
mockFetch((_, { body }) => expect(body).toBe(formData));
await post("/call_post", formData);
});

View file

@ -0,0 +1,147 @@
import { after, describe, expect, test } from "@odoo/hoot";
import { on } from "@odoo/hoot-dom";
import { mockFetch } from "@odoo/hoot-mock";
import {
ConnectionAbortedError,
ConnectionLostError,
RPCError,
rpc,
rpcBus,
} from "@web/core/network/rpc";
const onRpcRequest = (listener) => after(on(rpcBus, "RPC:REQUEST", listener));
const onRpcResponse = (listener) => after(on(rpcBus, "RPC:RESPONSE", listener));
describe.current.tags("headless");
test("can perform a simple rpc", async () => {
mockFetch((_, { body }) => {
const bodyObject = JSON.parse(body);
expect(bodyObject.jsonrpc).toBe("2.0");
expect(bodyObject.method).toBe("call");
expect(bodyObject.id).toBeOfType("integer");
return { result: { action_id: 123 } };
});
expect(await rpc("/test/")).toEqual({ action_id: 123 });
});
test("trigger an error when response has 'error' key", async () => {
mockFetch(() => ({
error: {
message: "message",
code: 12,
data: {
debug: "data_debug",
message: "data_message",
},
},
}));
const error = new RPCError("message");
await expect(rpc("/test/")).rejects.toThrow(error);
});
test("rpc with simple routes", async () => {
mockFetch((route, { body }) => ({
result: { route, params: JSON.parse(body).params },
}));
expect(await rpc("/my/route")).toEqual({ route: "/my/route", params: {} });
expect(await rpc("/my/route", { hey: "there", model: "test" })).toEqual({
route: "/my/route",
params: { hey: "there", model: "test" },
});
});
test("check trigger RPC:REQUEST and RPC:RESPONSE for a simple rpc", async () => {
mockFetch(() => ({ result: {} }));
const rpcIdsRequest = [];
const rpcIdsResponse = [];
onRpcRequest(({ detail }) => {
rpcIdsRequest.push(detail.data.id);
const silent = detail.settings.silent ? "(silent)" : "";
expect.step(`RPC:REQUEST${silent}`);
});
onRpcResponse(({ detail }) => {
rpcIdsResponse.push(detail.data.id);
const silent = detail.settings.silent ? "(silent)" : "";
const success = "result" in detail ? "(ok)" : "";
const fail = "error" in detail ? "(ko)" : "";
expect.step(`RPC:RESPONSE${silent}${success}${fail}`);
});
await rpc("/test/");
expect(rpcIdsRequest.toString()).toBe(rpcIdsResponse.toString());
expect.verifySteps(["RPC:REQUEST", "RPC:RESPONSE(ok)"]);
await rpc("/test/", {}, { silent: true });
expect(rpcIdsRequest.toString()).toBe(rpcIdsResponse.toString());
expect.verifySteps(["RPC:REQUEST(silent)", "RPC:RESPONSE(silent)(ok)"]);
});
test("check trigger RPC:REQUEST and RPC:RESPONSE for a rpc with an error", async () => {
mockFetch(() => ({
error: {
message: "message",
code: 12,
data: {
debug: "data_debug",
message: "data_message",
},
},
}));
const rpcIdsRequest = [];
const rpcIdsResponse = [];
onRpcRequest(({ detail }) => {
rpcIdsRequest.push(detail.data.id);
const silent = detail.settings.silent ? "(silent)" : "";
expect.step(`RPC:REQUEST${silent}`);
});
onRpcResponse(({ detail }) => {
rpcIdsResponse.push(detail.data.id);
const silent = detail.settings.silent ? "(silent)" : "";
const success = "result" in detail ? "(ok)" : "";
const fail = "error" in detail ? "(ko)" : "";
expect.step(`RPC:RESPONSE${silent}${success}${fail}`);
});
const error = new RPCError("message");
await expect(rpc("/test/")).rejects.toThrow(error);
expect.verifySteps(["RPC:REQUEST", "RPC:RESPONSE(ko)"]);
});
test("check connection aborted", async () => {
mockFetch(() => new Promise(() => {}));
onRpcRequest(() => expect.step("RPC:REQUEST"));
onRpcResponse(() => expect.step("RPC:RESPONSE"));
const connection = rpc();
connection.abort();
const error = new ConnectionAbortedError();
await expect(connection).rejects.toThrow(error);
expect.verifySteps(["RPC:REQUEST", "RPC:RESPONSE"]);
});
test("trigger a ConnectionLostError when response isn't json parsable", async () => {
mockFetch(() => new Response("<h...", { status: 500 }));
const error = new ConnectionLostError("/test/");
await expect(rpc("/test/")).rejects.toThrow(error);
});
test("rpc can send additional headers", async () => {
mockFetch((url, settings) => {
expect(settings.headers).toEqual({
"Content-Type": "application/json",
Hello: "World",
});
return { result: true };
});
await rpc("/test/", null, { headers: { Hello: "World" } });
});

View file

@ -0,0 +1,356 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import { click, queryFirst } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { Notebook } from "@web/core/notebook/notebook";
test("not rendered if empty slots", async () => {
await mountWithCleanup(Notebook);
expect("div.o_notebook").toHaveCount(0);
});
test("notebook with multiple pages given as slots", async () => {
class Parent extends Component {
static template = xml`<Notebook>
<t t-set-slot="page_about" title="'About'" isVisible="true">
<h3>About the bird</h3>
<p>Owls are birds from the order Strigiformes which includes over
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
</t>
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
<h3>Their favorite activity: hunting</h3>
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
</t>
<t t-set-slot="page_secret" title="'Secret about OWLs'" isVisible="false">
<p>TODO find a great secret about OWLs.</p>
</t>
</Notebook>`;
static components = { Notebook };
static props = ["*"];
}
await mountWithCleanup(Parent);
expect("div.o_notebook").toHaveCount(1);
expect(".o_notebook").toHaveClass("horizontal", {
message: "default orientation is set as horizontal",
});
expect(".nav").toHaveClass("flex-row", {
message: "navigation container uses the right class to display as horizontal tabs",
});
expect(".o_notebook_headers a.nav-link").toHaveCount(2, {
message: "navigation link is present for each visible page",
});
expect(".o_notebook_headers .nav-item:first-child a").toHaveClass("active", {
message: "first page is selected by default",
});
expect(".active h3").toHaveText("About the bird", {
message: "first page content is displayed by the notebook",
});
await click(".o_notebook_headers .nav-item:nth-child(2) a");
await animationFrame();
expect(".o_notebook_headers .nav-item:nth-child(2) a").toHaveClass("active", {
message: "second page is now selected",
});
expect(".active h3").toHaveText("Their favorite activity: hunting", {
message: "second page content is displayed by the notebook",
});
});
test("notebook with defaultPage props", async () => {
class Parent extends Component {
static template = xml`<Notebook defaultPage="'page_hunting'">
<t t-set-slot="page_about" title="'About'" isVisible="true">
<h3>About the bird</h3>
<p>Owls are birds from the order Strigiformes which includes over
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
</t>
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
<h3>Their favorite activity: hunting</h3>
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
</t>
<t t-set-slot="page_secret" title="'Secret about OWLs'" isVisible="false">
<p>TODO find a great secret about OWLs.</p>
</t>
</Notebook>`;
static components = { Notebook };
static props = ["*"];
}
await mountWithCleanup(Parent);
expect("div.o_notebook").toHaveCount(1);
expect(".o_notebook_headers .nav-item:nth-child(2) a").toHaveClass("active", {
message: "second page is selected by default",
});
expect(".active h3").toHaveText("Their favorite activity: hunting", {
message: "second page content is displayed by the notebook",
});
});
test("notebook with defaultPage set on invisible page", async () => {
class Parent extends Component {
static template = xml`<Notebook defaultPage="'page_secret'">
<t t-set-slot="page_about" title="'About'" isVisible="true">
<h3>About the bird</h3>
<p>Owls are birds from the order Strigiformes which includes over
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
</t>
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
<h3>Their favorite activity: hunting</h3>
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
</t>
<t t-set-slot="page_secret" title="'Secret about OWLs'" isVisible="false">
<h3>Oooops</h3>
<p>TODO find a great secret to reveal about OWLs.</p>
</t>
</Notebook>`;
static components = { Notebook };
static props = ["*"];
}
await mountWithCleanup(Parent);
expect(".o_notebook_headers .nav-item a.active").toHaveText("About", {
message: "The first page is selected",
});
});
test("notebook set vertically", async () => {
class Parent extends Component {
static template = xml`<Notebook orientation="'vertical'">
<t t-set-slot="page_about" title="'About'" isVisible="true">
<h3>About the bird</h3>
<p>Owls are birds from the order Strigiformes which includes over
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
</t>
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
<h3>Their favorite activity: hunting</h3>
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
</t>
</Notebook>`;
static components = { Notebook };
static props = ["*"];
}
await mountWithCleanup(Parent);
expect("div.o_notebook").toHaveCount(1);
expect(".o_notebook").toHaveClass("vertical", {
message: "orientation is set as vertical",
});
expect(".nav").toHaveClass("flex-column", {
message: "navigation container uses the right class to display as vertical buttons",
});
});
test("notebook pages rendered by a template component", async () => {
class NotebookPageRenderer extends Component {
static template = xml`
<h3 t-esc="props.heading"></h3>
<p t-esc="props.text" />
`;
static props = {
heading: String,
text: String,
};
}
class Parent extends Component {
static template = xml`<Notebook defaultPage="'page_three'" pages="pages">
<t t-set-slot="page_one" title="'Page 1'" isVisible="true">
<h3>Page 1</h3>
<p>First page set directly as a slot</p>
</t>
<t t-set-slot="page_four" title="'Page 4'" isVisible="true">
<h3>Page 4</h3>
</t>
</Notebook>`;
static components = { Notebook };
static props = ["*"];
setup() {
this.pages = [
{
Component: NotebookPageRenderer,
index: 1,
title: "Page 2",
props: {
heading: "Page 2",
text: "Second page rendered by a template component",
},
},
{
Component: NotebookPageRenderer,
id: "page_three", // required to be set as default page
index: 2,
title: "Page 3",
props: {
heading: "Page 3",
text: "Third page rendered by a template component",
},
},
];
}
}
await mountWithCleanup(Parent);
expect("div.o_notebook").toHaveCount(1);
expect(".o_notebook_headers .nav-item:nth-child(3) a").toHaveClass("active", {
message: "third page is selected by default",
});
await click(".o_notebook_headers .nav-item:nth-child(2) a");
await animationFrame();
expect(".o_notebook_content p").toHaveText("Second page rendered by a template component", {
message: "displayed content corresponds to the current page",
});
});
test("each page is different", async () => {
class Page extends Component {
static template = xml`<h3>Coucou</h3>`;
static props = ["*"];
}
class Parent extends Component {
static template = xml`<Notebook pages="pages"/>`;
static components = { Notebook };
static props = ["*"];
setup() {
this.pages = [
{
Component: Page,
index: 1,
title: "Page 1",
},
{
Component: Page,
index: 2,
title: "Page 2",
},
];
}
}
await mountWithCleanup(Parent);
const firstPage = queryFirst("h3");
expect(firstPage).toBeInstanceOf(HTMLElement);
await click(".o_notebook_headers .nav-item:nth-child(2) a");
await animationFrame();
const secondPage = queryFirst("h3");
expect(secondPage).toBeInstanceOf(HTMLElement);
expect(firstPage).not.toBe(secondPage);
});
test("defaultPage recomputed when isVisible is dynamic", async () => {
let defaultPageVisible = false;
class Parent extends Component {
static components = { Notebook };
static template = xml`
<Notebook defaultPage="'3'">
<t t-set-slot="1" title="'page1'" isVisible="true">
<div class="page1" />
</t>
<t t-set-slot="2" title="'page2'" isVisible="true">
<div class="page2" />
</t>
<t t-set-slot="3" title="'page3'" isVisible="defaultPageVisible">
<div class="page3" />
</t>
</Notebook>`;
static props = ["*"];
get defaultPageVisible() {
return defaultPageVisible;
}
}
const parent = await mountWithCleanup(Parent);
expect(".page1").toHaveCount(1);
expect(".nav-link.active").toHaveText("page1");
defaultPageVisible = true;
parent.render(true);
await animationFrame();
expect(".page3").toHaveCount(1);
expect(".nav-link.active").toHaveText("page3");
await click(".o_notebook_headers .nav-item:nth-child(2) a");
await animationFrame();
expect(".page2").toHaveCount(1);
expect(".nav-link.active").toHaveText("page2");
parent.render(true);
await animationFrame();
expect(".page2").toHaveCount(1);
expect(".nav-link.active").toHaveText("page2");
});
test("disabled pages are greyed out and can't be toggled", async () => {
class Parent extends Component {
static components = { Notebook };
static template = xml`
<Notebook defaultPage="'1'">
<t t-set-slot="1" title="'page1'" isVisible="true">
<div class="page1" />
</t>
<t t-set-slot="2" title="'page2'" isVisible="true" isDisabled="true">
<div class="page2" />
</t>
<t t-set-slot="3" title="'page3'" isVisible="true">
<div class="page3" />
</t>
</Notebook>`;
static props = ["*"];
}
await mountWithCleanup(Parent);
expect(".page1").toHaveCount(1);
expect(".nav-item:nth-child(2)").toHaveClass("disabled", {
message: "tab of the disabled page is greyed out",
});
await click(".nav-item:nth-child(2) .nav-link");
await animationFrame();
expect(".page1").toHaveCount(1, {
message: "the same page is still displayed",
});
await click(".nav-item:nth-child(3) .nav-link");
await animationFrame();
expect(".page3").toHaveCount(1, {
message: "the third page is now displayed",
});
});
test("icons can be given for each page tab", async () => {
class Parent extends Component {
static components = { Notebook };
static template = xml`
<Notebook defaultPage="'1'" icons="icons">
<t t-set-slot="1" title="'page1'" isVisible="true">
<div class="page1" />
</t>
<t t-set-slot="2" title="'page2'" isVisible="true">
<div class="page2" />
</t>
<t t-set-slot="3" title="'page3'" isVisible="true">
<div class="page3" />
</t>
</Notebook>`;
static props = ["*"];
get icons() {
return {
1: "fa-trash",
3: "fa-pencil",
};
}
}
await mountWithCleanup(Parent);
expect(".nav-item:nth-child(1) i").toHaveClass("fa-trash");
expect(".nav-item:nth-child(1)").toHaveText("page1");
expect(".nav-item:nth-child(2) i").toHaveCount(0);
expect(".nav-item:nth-child(2)").toHaveText("page2");
expect(".nav-item:nth-child(3) i").toHaveClass("fa-pencil");
expect(".nav-item:nth-child(3)").toHaveText("page3");
});

View file

@ -0,0 +1,315 @@
import { expect, test } from "@odoo/hoot";
import { click, hover, leave } from "@odoo/hoot-dom";
import { advanceTime, animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { markup } from "@odoo/owl";
import { getService, makeMockEnv, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { registry } from "@web/core/registry";
test("can display a basic notification", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("I'm a basic notification");
await animationFrame();
expect(".o_notification").toHaveCount(1);
expect(".o_notification_content").toHaveText("I'm a basic notification");
expect(".o_notification_bar").toHaveClass("bg-warning");
});
test("can display a notification with a className", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("I'm a basic notification", { className: "abc" });
await animationFrame();
expect(".o_notification.abc").toHaveCount(1);
});
test("title and message are escaped by default", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("<i>Some message</i>", { title: "<b>Some title</b>" });
await animationFrame();
expect(".o_notification").toHaveCount(1);
expect(".o_notification_title").toHaveText("<b>Some title</b>");
expect(".o_notification_content").toHaveText("<i>Some message</i>");
});
test("can display a notification with markup content", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add(markup("<b>I'm a <i>markup</i> notification</b>"));
await animationFrame();
expect(".o_notification").toHaveCount(1);
expect(".o_notification_content").toHaveInnerHTML("<b>I'm a <i>markup</i> notification</b>");
});
test("can display a notification of type danger", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("I'm a danger notification", { type: "danger" });
await animationFrame();
expect(".o_notification").toHaveCount(1);
expect(".o_notification_content").toHaveText("I'm a danger notification");
expect(".o_notification_bar").toHaveClass("bg-danger");
});
test("can display a danger notification with a title", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("I'm a danger notification", {
title: "Some title",
type: "danger",
});
await animationFrame();
expect(".o_notification").toHaveCount(1);
expect(".o_notification_title").toHaveText("Some title");
expect(".o_notification_content").toHaveText("I'm a danger notification");
expect(".o_notification_bar").toHaveClass("bg-danger");
});
test("can display a notification with a button", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("I'm a notification with button", {
buttons: [
{
name: "I'm a button",
primary: true,
onClick: () => {
expect.step("Button clicked");
},
},
],
});
await animationFrame();
expect(".o_notification").toHaveCount(1);
expect(".o_notification_buttons").toHaveText("I'm a button");
await click(".o_notification .btn-primary");
await animationFrame();
expect.verifySteps(["Button clicked"]);
expect(".o_notification").toHaveCount(1);
});
test("can display a notification with a callback when closed", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("I'm a sticky notification", {
sticky: true,
onClose: () => {
expect.step("Notification closed");
},
});
await animationFrame();
expect(".o_notification").toHaveCount(1);
await click(".o_notification .o_notification_close");
await animationFrame();
expect.verifySteps(["Notification closed"]);
expect(".o_notification").toHaveCount(0);
});
test("notifications aren't sticky by default", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("I'm a notification");
await animationFrame();
expect(".o_notification").toHaveCount(1);
// Wait for the notification to close
await runAllTimers();
expect(".o_notification").toHaveCount(0);
});
test("can display a sticky notification", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("I'm a sticky notification", { sticky: true });
await animationFrame();
expect(".o_notification").toHaveCount(1);
await advanceTime(5000);
await animationFrame();
expect(".o_notification").toHaveCount(1);
});
test("can close sticky notification", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
const closeNotif = getService("notification").add("I'm a sticky notification", {
sticky: true,
});
await animationFrame();
expect(".o_notification").toHaveCount(1);
// close programmatically
closeNotif();
await animationFrame();
expect(".o_notification").toHaveCount(0);
getService("notification").add("I'm a sticky notification", { sticky: true });
await animationFrame();
expect(".o_notification").toHaveCount(1);
// close by clicking on the close icon
await click(".o_notification .o_notification_close");
await animationFrame();
expect(".o_notification").toHaveCount(0);
});
// The timeout have to be done by the one that uses the notification service
test.skip("can close sticky notification with wait", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
const closeNotif = getService("notification").add("I'm a sticky notification", {
sticky: true,
});
await animationFrame();
expect(".o_notification").toHaveCount(1);
// close programmatically
getService("notification").close(closeNotif, 3000);
await animationFrame();
expect(".o_notification").toHaveCount(1);
// simulate end of timeout
await advanceTime(3000);
await animationFrame();
expect(".o_notification").toHaveCount(0);
});
test("can close a non-sticky notification", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
const closeNotif = getService("notification").add("I'm a sticky notification");
await animationFrame();
expect(".o_notification").toHaveCount(1);
// close the notification
closeNotif();
await animationFrame();
expect(".o_notification").toHaveCount(0);
// simulate end of timeout, which should try to close the notification as well
await runAllTimers();
expect(".o_notification").toHaveCount(0);
});
test.tags("desktop");
test("can refresh the duration of a non-sticky notification", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("I'm a first non-sticky notification");
getService("notification").add("I'm a second non-sticky notification");
await animationFrame();
expect(".o_notification").toHaveCount(2);
await advanceTime(3000);
await hover(".o_notification:first-child");
await advanceTime(5000);
// Both notifications should be visible as long as mouse is over one of them
expect(".o_notification").toHaveCount(2);
await leave();
await advanceTime(3000);
// Both notifications should be refreshed in duration (4000 ms)
expect(".o_notification").toHaveCount(2);
await advanceTime(2000);
expect(".o_notification").toHaveCount(0);
});
test("close a non-sticky notification while another one remains", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
const closeNotif1 = getService("notification").add("I'm a non-sticky notification");
const closeNotif2 = getService("notification").add("I'm a sticky notification", {
sticky: true,
});
await animationFrame();
expect(".o_notification").toHaveCount(2);
// close the non sticky notification
closeNotif1();
await animationFrame();
expect(".o_notification").toHaveCount(1);
// simulate end of timeout, which should try to close notification 1 as well
await runAllTimers();
expect(".o_notification").toHaveCount(1);
// close the non sticky notification
closeNotif2();
await animationFrame();
expect(".o_notification").toHaveCount(0);
});
test("notification coming when NotificationManager not mounted yet", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("I'm a non-sticky notification");
await animationFrame();
expect(".o_notification").toHaveCount(1);
});
test("notification autocloses after a specified delay", async () => {
await makeMockEnv();
const { Component: NotificationContainer, props } = registry
.category("main_components")
.get("NotificationContainer");
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
getService("notification").add("custom autoclose delay notification", {
autocloseDelay: 1000,
});
await advanceTime(500);
await animationFrame();
expect(".o_notification").toHaveCount(1);
await advanceTime(500);
await animationFrame();
expect(".o_notification").toHaveCount(0);
});

View file

@ -0,0 +1,511 @@
import { after, describe, expect, test } from "@odoo/hoot";
import { on } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { getService, makeMockEnv, mountWithCleanup, onRpc } from "@web/../tests/web_test_helpers";
import { rpcBus } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
describe.current.tags("headless");
test("add user context to a simple read request", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [[3], ["id", "descr"]],
kwargs: {
context: {
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "read",
model: "res.partner",
});
return false; // Don't want to call the actual read method
});
const { services } = await makeMockEnv();
await services.orm.read("res.partner", [3], ["id", "descr"]);
expect.verifySteps(["/web/dataset/call_kw/res.partner/read"]);
});
test("context is combined with user context in read request", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [[3], ["id", "descr"]],
kwargs: {
context: {
allowed_company_ids: [1],
earth: "isfucked",
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "read",
model: "res.partner",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.read("res.partner", [3], ["id", "descr"], {
context: {
earth: "isfucked",
},
});
expect.verifySteps(["/web/dataset/call_kw/res.partner/read"]);
});
test("basic method call of model", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [],
kwargs: {
context: {
a: 1,
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "test",
model: "res.partner",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.call("res.partner", "test", [], { context: { a: 1 } });
expect.verifySteps(["/web/dataset/call_kw/res.partner/test"]);
});
test("create method: one record", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [[{ color: "red" }]],
kwargs: {
context: {
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "create",
model: "res.partner",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.create("res.partner", [{ color: "red" }]);
expect.verifySteps(["/web/dataset/call_kw/res.partner/create"]);
});
test("create method: several records", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [[{ color: "red" }, { color: "green" }]],
kwargs: {
context: {
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "create",
model: "res.partner",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.create("res.partner", [{ color: "red" }, { color: "green" }]);
expect.verifySteps(["/web/dataset/call_kw/res.partner/create"]);
});
test("read method", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [
[2, 5],
["name", "amount"],
],
kwargs: {
load: "none",
context: {
abc: 3,
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "read",
model: "sale.order",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.read("sale.order", [2, 5], ["name", "amount"], {
load: "none",
context: { abc: 3 },
});
expect.verifySteps(["/web/dataset/call_kw/sale.order/read"]);
});
test("unlink method", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [[43]],
kwargs: {
context: {
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "unlink",
model: "res.partner",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.unlink("res.partner", [43]);
expect.verifySteps(["/web/dataset/call_kw/res.partner/unlink"]);
});
test("write method", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [[43, 14], { active: false }],
kwargs: {
context: {
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "write",
model: "res.partner",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.write("res.partner", [43, 14], { active: false });
expect.verifySteps(["/web/dataset/call_kw/res.partner/write"]);
});
test("webReadGroup method", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [],
kwargs: {
domain: [["user_id", "=", 2]],
fields: ["amount_total:sum"],
groupby: ["date_order"],
context: {
allowed_company_ids: [1],
lang: "en",
uid: 7,
tz: "taht",
},
offset: 1,
},
method: "web_read_group",
model: "sale.order",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.webReadGroup(
"sale.order",
[["user_id", "=", 2]],
["amount_total:sum"],
["date_order"],
{ offset: 1 }
);
expect.verifySteps(["/web/dataset/call_kw/sale.order/web_read_group"]);
});
test("readGroup method", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [],
kwargs: {
domain: [["user_id", "=", 2]],
fields: ["amount_total:sum"],
groupby: ["date_order"],
context: {
allowed_company_ids: [1],
lang: "en",
uid: 7,
tz: "taht",
},
offset: 1,
},
method: "read_group",
model: "sale.order",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.readGroup(
"sale.order",
[["user_id", "=", 2]],
["amount_total:sum"],
["date_order"],
{ offset: 1 }
);
expect.verifySteps(["/web/dataset/call_kw/sale.order/read_group"]);
});
test("test readGroup method removes duplicate values from groupby", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params.kwargs.groupby).toMatchObject(["date_order:month"], {
message: "Duplicate values should be removed from groupby",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.readGroup(
"sale.order",
[["user_id", "=", 2]],
["amount_total:sum"],
["date_order:month", "date_order:month"],
{ offset: 1 }
);
expect.verifySteps(["/web/dataset/call_kw/sale.order/read_group"]);
});
test("search_read method", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [],
kwargs: {
context: {
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
},
domain: [["user_id", "=", 2]],
fields: ["amount_total"],
},
method: "search_read",
model: "sale.order",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.searchRead("sale.order", [["user_id", "=", 2]], ["amount_total"]);
expect.verifySteps(["/web/dataset/call_kw/sale.order/search_read"]);
});
test("search_count method", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [[["user_id", "=", 2]]],
kwargs: {
context: {
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "search_count",
model: "sale.order",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.searchCount("sale.order", [["user_id", "=", 2]]);
expect.verifySteps(["/web/dataset/call_kw/sale.order/search_count"]);
});
test("webRead method", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [[2, 5]],
kwargs: {
specification: { name: {}, amount: {} },
context: {
abc: 3,
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "web_read",
model: "sale.order",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.webRead("sale.order", [2, 5], {
specification: { name: {}, amount: {} },
context: { abc: 3 },
});
expect.verifySteps(["/web/dataset/call_kw/sale.order/web_read"]);
});
test("webSearchRead method", async () => {
onRpc(async (params) => {
expect.step(params.route);
expect(params).toMatchObject({
args: [],
kwargs: {
context: {
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
},
domain: [["user_id", "=", 2]],
specification: { amount_total: {} },
},
method: "web_search_read",
model: "sale.order",
});
return false;
});
const { services } = await makeMockEnv();
await services.orm.webSearchRead("sale.order", [["user_id", "=", 2]], {
specification: { amount_total: {} },
});
expect.verifySteps(["/web/dataset/call_kw/sale.order/web_search_read"]);
});
test("orm is specialized for component", async () => {
await makeMockEnv();
class MyComponent extends Component {
static props = {};
static template = xml`<div />`;
setup() {
this.orm = useService("orm");
}
}
const component = await mountWithCleanup(MyComponent);
expect(component.orm).not.toBe(getService("orm"));
});
test("silent mode", async () => {
onRpc((params) => {
expect.step(params.route);
return false;
});
const { services } = await makeMockEnv();
after(
on(rpcBus, "RPC:RESPONSE", (ev) =>
expect.step(`response${ev.detail.settings.silent ? " (silent)" : ""}`)
)
);
await services.orm.call("res.partner", "partner_method");
await services.orm.silent.call("res.partner", "partner_method");
await services.orm.call("res.partner", "partner_method");
await services.orm.read("res.partner", [1], []);
await services.orm.silent.read("res.partner", [1], []);
await services.orm.read("res.partner", [1], []);
expect.verifySteps([
"/web/dataset/call_kw/res.partner/partner_method",
"response",
"/web/dataset/call_kw/res.partner/partner_method",
"response (silent)",
"/web/dataset/call_kw/res.partner/partner_method",
"response",
"/web/dataset/call_kw/res.partner/read",
"response",
"/web/dataset/call_kw/res.partner/read",
"response (silent)",
"/web/dataset/call_kw/res.partner/read",
"response",
]);
});
test("validate some obviously wrong calls", async () => {
expect.assertions(2);
const { services } = await makeMockEnv();
expect(() => services.orm.read(false, [3], ["id", "descr"])).toThrow(
"Invalid model name: false"
);
expect(() => services.orm.read("res.res.partner", false, ["id", "descr"])).toThrow(
"Invalid ids list: false"
);
});
test("optimize read and unlink if no ids", async () => {
onRpc((params) => {
expect.step(params.route);
return false;
});
const { services } = await makeMockEnv();
await services.orm.read("res.partner", [1], []);
expect.verifySteps(["/web/dataset/call_kw/res.partner/read"]);
await services.orm.read("res.partner", [], []);
expect.verifySteps([]);
await services.orm.unlink("res.partner", [1], {});
expect.verifySteps(["/web/dataset/call_kw/res.partner/unlink"]);
await services.orm.unlink("res.partner", [], {});
expect.verifySteps([]);
});

View file

@ -0,0 +1,154 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, useSubEnv, xml } from "@odoo/owl";
import { getService, makeMockEnv, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { MainComponentsContainer } from "@web/core/main_components_container";
test("simple case", async () => {
await mountWithCleanup(MainComponentsContainer);
expect(".o-overlay-container").toHaveCount(1);
class MyComp extends Component {
static template = xml`
<div class="overlayed"></div>
`;
static props = ["*"];
}
const remove = getService("overlay").add(MyComp, {});
await animationFrame();
expect(".o-overlay-container .overlayed").toHaveCount(1);
remove();
await animationFrame();
expect(".o-overlay-container .overlayed").toHaveCount(0);
});
test("shadow DOM overlays are visible when registered before main component is mounted", async () => {
class MyComp extends Component {
static template = xml`
<div class="overlayed"></div>
`;
static props = ["*"];
}
const root = document.createElement("div");
root.setAttribute("id", "my-root-id");
root.attachShadow({ mode: "open" });
getFixture().appendChild(root);
await makeMockEnv();
getService("overlay").add(MyComp, {}, { rootId: "my-root-id" });
await mountWithCleanup(MainComponentsContainer, { target: root.shadowRoot });
await animationFrame();
expect("#my-root-id:shadow .o-overlay-container .overlayed").toHaveCount(1);
});
test("onRemove callback", async () => {
await mountWithCleanup(MainComponentsContainer);
class MyComp extends Component {
static template = xml``;
static props = ["*"];
}
const onRemove = () => expect.step("onRemove");
const remove = getService("overlay").add(MyComp, {}, { onRemove });
expect.verifySteps([]);
remove();
expect.verifySteps(["onRemove"]);
});
test("multiple overlays", async () => {
await mountWithCleanup(MainComponentsContainer);
class MyComp extends Component {
static template = xml`
<div class="overlayed" t-att-class="props.className"></div>
`;
static props = ["*"];
}
const remove1 = getService("overlay").add(MyComp, { className: "o1" });
const remove2 = getService("overlay").add(MyComp, { className: "o2" });
const remove3 = getService("overlay").add(MyComp, { className: "o3" });
await animationFrame();
expect(".overlayed").toHaveCount(3);
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o1");
expect(".o-overlay-container :nth-child(2) .overlayed").toHaveClass("o2");
expect(".o-overlay-container :nth-child(3) .overlayed").toHaveClass("o3");
remove1();
await animationFrame();
expect(".overlayed").toHaveCount(2);
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o2");
expect(".o-overlay-container :nth-child(2) .overlayed").toHaveClass("o3");
remove2();
await animationFrame();
expect(".overlayed").toHaveCount(1);
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o3");
remove3();
await animationFrame();
expect(".overlayed").toHaveCount(0);
});
test("sequence", async () => {
await mountWithCleanup(MainComponentsContainer);
class MyComp extends Component {
static template = xml`
<div class="overlayed" t-att-class="props.className"></div>
`;
static props = ["*"];
}
const remove1 = getService("overlay").add(MyComp, { className: "o1" }, { sequence: 50 });
const remove2 = getService("overlay").add(MyComp, { className: "o2" }, { sequence: 60 });
const remove3 = getService("overlay").add(MyComp, { className: "o3" }, { sequence: 40 });
await animationFrame();
expect(".overlayed").toHaveCount(3);
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o3");
expect(".o-overlay-container :nth-child(2) .overlayed").toHaveClass("o1");
expect(".o-overlay-container :nth-child(3) .overlayed").toHaveClass("o2");
remove1();
await animationFrame();
expect(".overlayed").toHaveCount(2);
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o3");
expect(".o-overlay-container :nth-child(2) .overlayed").toHaveClass("o2");
remove2();
await animationFrame();
expect(".overlayed").toHaveCount(1);
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o3");
remove3();
await animationFrame();
expect(".overlayed").toHaveCount(0);
});
test("allow env as option", async () => {
await mountWithCleanup(MainComponentsContainer);
class MyComp extends Component {
static props = ["*"];
static template = xml`
<ul class="outer">
<li>A=<t t-out="env.A"/></li>
<li>B=<t t-out="env.B"/></li>
</ul>
`;
setup() {
useSubEnv({ A: "blip" });
}
}
getService("overlay").add(MyComp, {}, { env: { A: "foo", B: "bar" } });
await animationFrame();
expect(".o-overlay-container li:nth-child(1)").toHaveText("A=blip");
expect(".o-overlay-container li:nth-child(2)").toHaveText("B=bar");
});

View file

@ -0,0 +1,371 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { queryOne, resize, scroll, waitFor } from "@odoo/hoot-dom";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { Popover } from "@web/core/popover/popover";
import { usePosition } from "@web/core/position/position_hook";
class Content extends Component {
static props = ["*"];
static template = xml`<div id="popover">Popover Content</div>`;
}
test("popover can have custom class", async () => {
await mountWithCleanup(Popover, {
props: {
target: getFixture(),
class: "custom-popover",
component: Content,
},
});
expect(".o_popover.custom-popover").toHaveCount(1);
});
test("popover can have more than one custom class", async () => {
await mountWithCleanup(Popover, {
props: {
target: getFixture(),
class: "custom-popover popover-custom",
component: Content,
},
});
expect(".o_popover.custom-popover.popover-custom").toHaveCount(1);
});
test("popover is rendered nearby target (default)", async () => {
expect.assertions(2);
class TestPopover extends Popover {
onPositioned(el, { direction, variant }) {
expect(direction).toBe("bottom");
expect(variant).toBe("middle");
}
}
await mountWithCleanup(TestPopover, {
props: {
target: getFixture(),
class: "custom-popover popover-custom",
component: Content,
},
});
});
test("popover is rendered nearby target (bottom)", async () => {
expect.assertions(2);
class TestPopover extends Popover {
onPositioned(el, { direction, variant }) {
expect(direction).toBe("bottom");
expect(variant).toBe("middle");
}
}
await mountWithCleanup(TestPopover, {
props: {
target: getFixture(),
position: "bottom",
component: Content,
},
});
});
test("popover is rendered nearby target (top)", async () => {
expect.assertions(2);
class TestPopover extends Popover {
onPositioned(el, { direction, variant }) {
expect(direction).toBe("top");
expect(variant).toBe("middle");
}
}
await mountWithCleanup(TestPopover, {
props: {
target: getFixture(),
position: "top",
component: Content,
},
});
});
test("popover is rendered nearby target (left)", async () => {
expect.assertions(2);
class TestPopover extends Popover {
onPositioned(el, { direction, variant }) {
expect(direction).toBe("left");
expect(variant).toBe("middle");
}
}
await mountWithCleanup(TestPopover, {
props: {
target: getFixture(),
position: "left",
component: Content,
},
});
});
test("popover is rendered nearby target (right)", async () => {
expect.assertions(2);
class TestPopover extends Popover {
onPositioned(el, { direction, variant }) {
expect(direction).toBe("right");
expect(variant).toBe("middle");
}
}
await mountWithCleanup(TestPopover, {
props: {
target: getFixture(),
position: "right",
component: Content,
},
});
});
test("popover is rendered nearby target (bottom-start)", async () => {
expect.assertions(2);
class TestPopover extends Popover {
onPositioned(el, { direction, variant }) {
expect(direction).toBe("bottom");
expect(variant).toBe("start");
}
}
await mountWithCleanup(TestPopover, {
props: {
target: getFixture(),
position: "bottom-start",
component: Content,
},
});
});
test("popover is rendered nearby target (bottom-middle)", async () => {
expect.assertions(2);
class TestPopover extends Popover {
onPositioned(el, { direction, variant }) {
expect(direction).toBe("bottom");
expect(variant).toBe("middle");
}
}
await mountWithCleanup(TestPopover, {
props: {
target: getFixture(),
position: "bottom-middle",
component: Content,
},
});
});
test("popover is rendered nearby target (bottom-end)", async () => {
expect.assertions(2);
class TestPopover extends Popover {
onPositioned(el, { direction, variant }) {
expect(direction).toBe("bottom");
expect(variant).toBe("end");
}
}
await mountWithCleanup(TestPopover, {
props: {
target: getFixture(),
position: "bottom-end",
component: Content,
},
});
});
test("popover is rendered nearby target (bottom-fit)", async () => {
expect.assertions(2);
class TestPopover extends Popover {
onPositioned(el, { direction, variant }) {
expect(direction).toBe("bottom");
expect(variant).toBe("fit");
}
}
await mountWithCleanup(TestPopover, {
props: {
target: getFixture(),
position: "bottom-fit",
component: Content,
},
});
});
test("reposition popover should properly change classNames", async () => {
await resize({ height: 300 });
class TestPopover extends Popover {
setup() {
// Don't call super.setup() in order to replace the use of usePosition hook...
usePosition("ref", () => this.props.target, {
container,
onPositioned: this.onPositioned.bind(this),
position: this.props.position,
});
}
}
// Force some style, to make this test independent of screen size
await mountWithCleanup(/* xml */ `
<div class="container" style="width: 450px; height: 450px; display: flex; align-items: center; justify-content: center;">
<div class="popover-target" style="width: 50px; height: 50px;" />
</div>
`);
const container = queryOne(".container");
await mountWithCleanup(TestPopover, {
props: {
target: queryOne(".popover-target"),
component: Content,
},
});
const popover = queryOne("#popover");
popover.style.height = "100px";
popover.style.width = "100px";
// Should have classes for a "bottom-middle" placement
expect(".o_popover").toHaveClass(
"o_popover popover mw-100 o-popover--with-arrow bs-popover-bottom o-popover-bottom o-popover--bm"
);
expect(".popover-arrow").toHaveClass("popover-arrow start-0 end-0 mx-auto");
// Change container style and force update
container.style.height = "125px"; // height of popper + 1/2 reference
container.style.alignItems = "flex-end";
await resize();
await runAllTimers();
await animationFrame();
expect(".o_popover").toHaveClass(
"o_popover popover mw-100 o-popover--with-arrow bs-popover-end o-popover-right o-popover--re"
);
expect(".popover-arrow").toHaveClass("popover-arrow top-auto");
});
test("within iframe", async () => {
let popoverEl;
class TestPopover extends Popover {
onPositioned(el, { direction }) {
popoverEl = el;
expect.step(direction);
}
}
await mountWithCleanup(/* xml */ `
<iframe class="container" style="height: 200px; display: flex" srcdoc="&lt;div id='target' style='height:400px;'&gt;Within iframe&lt;/div&gt;" />
`);
await waitFor(":iframe #target");
const popoverTarget = queryOne(":iframe #target");
await mountWithCleanup(TestPopover, {
props: {
target: popoverTarget,
component: Content,
animation: false,
},
});
expect.verifySteps(["bottom"]);
expect(".o_popover").toHaveCount(1);
expect(":iframe .o_popover").toHaveCount(0);
// The popover should be rendered in the correct position
const marginTop = parseFloat(getComputedStyle(popoverEl).marginTop);
const { top: targetTop, left: targetLeft } = popoverTarget.getBoundingClientRect();
const { top: iframeTop, left: iframeLeft } = queryOne("iframe").getBoundingClientRect();
let popoverBox = popoverEl.getBoundingClientRect();
let expectedTop = iframeTop + targetTop + popoverTarget.offsetHeight + marginTop;
const expectedLeft =
iframeLeft + targetLeft + (popoverTarget.offsetWidth - popoverBox.width) / 2;
expect(Math.floor(popoverBox.top)).toBe(Math.floor(expectedTop));
expect(Math.floor(popoverBox.left)).toBe(Math.floor(expectedLeft));
await scroll(popoverTarget.ownerDocument.documentElement, { y: 100 }, { scrollable: false });
await animationFrame();
expect.verifySteps(["bottom"]);
popoverBox = popoverEl.getBoundingClientRect();
expectedTop -= 100;
expect(Math.floor(popoverBox.top)).toBe(Math.floor(expectedTop));
expect(Math.floor(popoverBox.left)).toBe(Math.floor(expectedLeft));
});
test("within iframe -- wrong element class", async () => {
class TestPopover extends Popover {
static props = {
...Popover.props,
target: {
validate: (...args) => {
const val = Popover.props.target.validate(...args);
expect.step(`validate target props: "${val}"`);
return val;
},
},
};
}
await mountWithCleanup(/* xml */ `
<iframe class="container" style="height: 200px; display: flex" srcdoc="&lt;div id='target' style='height:400px;'&gt;Within iframe&lt;/div&gt;" />
`);
await waitFor(":iframe #target");
const wrongElement = document.createElement("div");
wrongElement.classList.add("wrong-element");
queryOne(":iframe body").appendChild(wrongElement);
await mountWithCleanup(TestPopover, {
props: {
target: wrongElement,
component: Content,
},
});
expect(".o_popover").toHaveCount(1);
expect.verifySteps(['validate target props: "true"']);
});
test("popover fixed position", async () => {
class TestPopover extends Popover {
onPositioned() {
expect.step("onPositioned");
}
}
await resize({ width: 450, height: 450 });
await mountWithCleanup(/* xml */ `
<div class="container w-100 h-100" style="display: flex">
<div class="popover-target" style="width: 50px; height: 50px;" />
</div>
`);
const container = queryOne(".container");
await mountWithCleanup(TestPopover, {
props: {
target: container,
position: "bottom-fit",
fixedPosition: true,
component: Content,
},
});
expect(".o_popover").toHaveCount(1);
expect.verifySteps(["onPositioned"]);
// force the DOM update
container.style.alignItems = "flex-end";
await resize({ height: 125 });
await animationFrame();
expect.verifySteps([]);
});

View file

@ -0,0 +1,94 @@
import { test, expect, getFixture, destroy } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import { usePopover } from "@web/core/popover/popover_hook";
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
test("close popover when component is unmounted", async () => {
const target = getFixture();
class Comp extends Component {
static template = xml`<div t-att-id="props.id">in popover</div>`;
static props = ["*"];
}
class CompWithPopover extends Component {
static template = xml`<div />`;
static props = ["*"];
setup() {
this.popover = usePopover(Comp);
}
}
const comp1 = await mountWithCleanup(CompWithPopover);
comp1.popover.open(target, { id: "comp1" });
await animationFrame();
const comp2 = await mountWithCleanup(CompWithPopover, { noMainContainer: true });
comp2.popover.open(target, { id: "comp2" });
await animationFrame();
expect(".o_popover").toHaveCount(2);
expect(".o_popover #comp1").toHaveCount(1);
expect(".o_popover #comp2").toHaveCount(1);
destroy(comp1);
await animationFrame();
expect(".o_popover").toHaveCount(1);
expect(".o_popover #comp1").toHaveCount(0);
expect(".o_popover #comp2").toHaveCount(1);
destroy(comp2);
await animationFrame();
expect(".o_popover").toHaveCount(0);
expect(".o_popover #comp1").toHaveCount(0);
expect(".o_popover #comp2").toHaveCount(0);
});
test("popover opened from another", async () => {
class Comp extends Component {
static id = 0;
static template = xml`
<div class="p-4">
<button class="pop-open" t-on-click="(ev) => this.popover.open(ev.target, {})">open popover</button>
</div>
`;
static props = ["*"];
setup() {
this.popover = usePopover(Comp, {
popoverClass: `popover-${++Comp.id}`,
});
}
}
await mountWithCleanup(Comp);
await contains(".pop-open").click();
expect(".popover-1").toHaveCount(1);
await contains(".popover-1 .pop-open").click();
expect(".o_popover").toHaveCount(2);
expect(".popover-1").toHaveCount(1);
expect(".popover-2").toHaveCount(1);
await contains(".popover-2 .pop-open").click();
expect(".o_popover").toHaveCount(3);
expect(".popover-1").toHaveCount(1);
expect(".popover-2").toHaveCount(1);
expect(".popover-3").toHaveCount(1);
await contains(".popover-3").click();
expect(".o_popover").toHaveCount(3);
expect(".popover-1").toHaveCount(1);
expect(".popover-2").toHaveCount(1);
expect(".popover-3").toHaveCount(1);
await contains(".popover-2").click();
expect(".o_popover").toHaveCount(2);
expect(".popover-1").toHaveCount(1);
expect(".popover-2").toHaveCount(1);
await contains(document.body).click();
expect(".o_popover").toHaveCount(0);
});

View file

@ -0,0 +1,233 @@
import { Component, onWillStart, xml } from "@odoo/owl";
import { test, expect, beforeEach, getFixture } from "@odoo/hoot";
import { getService, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { animationFrame } from "@odoo/hoot-mock";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { click, press } from "@odoo/hoot-dom";
import { Deferred } from "@web/core/utils/concurrency";
let target;
beforeEach(async () => {
await mountWithCleanup(MainComponentsContainer);
target = getFixture();
});
test("simple use", async () => {
class Comp extends Component {
static template = xml`<div id="comp">in popover</div>`;
static props = ["*"];
}
expect(".o_popover").toHaveCount(0);
const remove = getService("popover").add(target, Comp);
await animationFrame();
expect(".o_popover").toHaveCount(1);
expect(".o_popover #comp").toHaveCount(1);
remove();
await animationFrame();
expect(".o_popover").toHaveCount(0);
expect(".o_popover #comp").toHaveCount(0);
});
test("close on click away", async () => {
class Comp extends Component {
static template = xml`<div id="comp">in popover</div>`;
static props = ["*"];
}
getService("popover").add(target, Comp);
await animationFrame();
expect(".o_popover").toHaveCount(1);
expect(".o_popover #comp").toHaveCount(1);
await click(document.body);
await animationFrame();
expect(".o_popover").toHaveCount(0);
expect(".o_popover #comp").toHaveCount(0);
});
test("close on click away when loading", async () => {
const def = new Deferred();
class Comp extends Component {
static template = xml`<div id="comp">in popover</div>`;
static props = ["*"];
setup() {
onWillStart(async () => {
await def;
});
}
}
getService("popover").add(target, Comp);
await animationFrame();
expect(".o_popover").toHaveCount(0);
expect(".o_popover #comp").toHaveCount(0);
click(document.body);
await animationFrame();
expect(".o_popover").toHaveCount(0);
expect(".o_popover #comp").toHaveCount(0);
def.resolve();
await animationFrame();
expect(".o_popover").toHaveCount(0);
expect(".o_popover #comp").toHaveCount(0);
});
test.tags("desktop");
test("close on 'Escape' keydown", async () => {
class Comp extends Component {
static template = xml`<div id="comp">in popover</div>`;
static props = ["*"];
}
getService("popover").add(target, Comp);
await animationFrame();
expect(".o_popover").toHaveCount(1);
expect(".o_popover #comp").toHaveCount(1);
await press("Escape");
await animationFrame();
expect(".o_popover").toHaveCount(0);
expect(".o_popover #comp").toHaveCount(0);
});
test("do not close on click away", async () => {
class Comp extends Component {
static template = xml`<div id="comp">in popover</div>`;
static props = ["*"];
}
const remove = getService("popover").add(target, Comp, {}, { closeOnClickAway: false });
await animationFrame();
expect(".o_popover").toHaveCount(1);
expect(".o_popover #comp").toHaveCount(1);
await click(document.body);
await animationFrame();
expect(".o_popover").toHaveCount(1);
expect(".o_popover #comp").toHaveCount(1);
remove();
await animationFrame();
expect(".o_popover").toHaveCount(0);
expect(".o_popover #comp").toHaveCount(0);
});
test("close callback", async () => {
class Comp extends Component {
static template = xml`<div id="comp">in popover</div>`;
static props = ["*"];
}
function onClose() {
expect.step("close");
}
getService("popover").add(target, Comp, {}, { onClose });
await animationFrame();
await click(document.body);
await animationFrame();
expect.verifySteps(["close"]);
});
test("sub component triggers close", async () => {
class Comp extends Component {
static template = xml`<div id="comp" t-on-click="() => this.props.close()">in popover</div>`;
static props = ["*"];
}
getService("popover").add(target, Comp);
await animationFrame();
expect(".o_popover").toHaveCount(1);
expect(".o_popover #comp").toHaveCount(1);
await click("#comp");
await animationFrame();
expect(".o_popover").toHaveCount(0);
expect(".o_popover #comp").toHaveCount(0);
});
test("close popover if target is removed", async () => {
class Comp extends Component {
static template = xml`<div id="comp">in popover</div>`;
static props = ["*"];
}
getService("popover").add(target, Comp);
await animationFrame();
expect(".o_popover").toHaveCount(1);
expect(".o_popover #comp").toHaveCount(1);
target.remove();
await animationFrame();
expect(".o_popover").toHaveCount(0);
expect(".o_popover #comp").toHaveCount(0);
});
test("close and do not crash if target parent does not exist", async () => {
// This target does not have any parent, it simulates the case where the element disappeared
// from the DOM before the setup of the component
const dissapearedTarget = document.createElement("div");
class Comp extends Component {
static template = xml`<div id="comp">in popover</div>`;
static props = ["*"];
}
function onClose() {
expect.step("close");
}
getService("popover").add(dissapearedTarget, Comp, {}, { onClose });
await animationFrame();
expect.verifySteps(["close"]);
});
test("keep popover if target sibling is removed", async () => {
class Comp extends Component {
static template = xml`<div id="comp">in popover</div>`;
static props = ["*"];
}
class Sibling extends Component {
static template = xml`<div id="sibling">Sibling</div>`;
static props = ["*"];
}
await mountWithCleanup(Sibling, { noMainContainer: true });
getService("popover").add(target, Comp);
await animationFrame();
expect(".o_popover").toHaveCount(1);
expect(".o_popover #comp").toHaveCount(1);
target.querySelector("#sibling").remove();
await animationFrame();
expect(".o_popover").toHaveCount(1);
expect(".o_popover #comp").toHaveCount(1);
});

Some files were not shown because too many files have changed in this diff Show more