vanilla 19.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:49:46 +02:00
parent 991d2234ca
commit d1963a3c3a
3066 changed files with 1651266 additions and 922560 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,185 @@
import { after, destroy, getFixture } from "@odoo/hoot";
import { queryFirst, queryOne } from "@odoo/hoot-dom";
import { App, Component, xml } from "@odoo/owl";
import { appTranslateFn } from "@web/core/l10n/translation";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { getPopoverForTarget } from "@web/core/popover/popover";
import { getTemplate as defaultGetTemplate } from "@web/core/templates";
import { isIterable } from "@web/core/utils/arrays";
import { patch } from "@web/core/utils/patch";
import {
customDirectives as defaultCustomDirectives,
globalValues as defaultGlobalValues,
} from "@web/env";
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) {
if (getMockEnv().isSmall) {
return queryFirst(".o-dropdown--menu", { eq: -1 });
}
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 = defaultCustomDirectives,
env,
fixtureClassName = "o_web_client",
getTemplate = defaultGetTemplate,
globalValues = defaultGlobalValues,
noMainContainer,
props,
target,
templates,
translatableAttributes,
translateFn = appTranslateFn,
} = 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,439 @@
import { after, afterEach } from "@odoo/hoot";
import {
check,
clear,
click,
dblclick,
drag,
edit,
fill,
getActiveElement,
hover,
keyDown,
keyUp,
manuallyDispatchProgrammaticEvent,
pointerDown,
press,
queryOne,
queryRect,
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,
});
}
/**
* Dragging methods taking into account the fact that it's the top of the
* dragged element that triggers the moves (not the position of the cursor),
* and the fact that during the first move, the dragged element is replaced by
* a placeholder that does not have the same height. The moves are done with
* the same x position to prevent triggering horizontal moves.
*
* @param {Target} from
* @param {DragAndDropOptions} [options]
*/
export async function sortableDrag(from, options) {
const fromRect = queryRect(from);
const { cancel, drop, moveTo } = await contains(from).drag({
initialPointerMoveDistance: 0,
...options,
});
let isFirstMove = true;
/**
* @param {string} [targetSelector]
*/
const moveAbove = async (targetSelector) => {
await moveTo(targetSelector, {
position: {
x: fromRect.x - queryRect(targetSelector).x + fromRect.width / 2,
y: fromRect.height / 2 + 5,
},
relative: true,
});
isFirstMove = false;
};
/**
* @param {string} [targetSelector]
*/
const moveUnder = async (targetSelector) => {
const elRect = queryRect(targetSelector);
// Need to consider that the moved element will be replaced by a
// placeholder with a height of 5px
const firstMoveBelow = isFirstMove && elRect.y > fromRect.y;
await moveTo(targetSelector, {
position: {
x: fromRect.x - elRect.x + fromRect.width / 2,
y:
((firstMoveBelow ? -1 : 1) * fromRect.height) / 2 +
elRect.height +
(firstMoveBelow ? 4 : -1),
},
relative: true,
});
isFirstMove = false;
};
return { cancel, moveAbove, moveTo, moveUnder, drop };
}

View file

@ -0,0 +1,203 @@
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,
translatedTermsGlobal,
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];
}
for (const key in translatedTermsGlobal) {
delete translatedTermsGlobal[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 div").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_group_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,41 @@
export function mockIndexedDB(_name, { fn }) {
return (requireModule, ...args) => {
const indexedDBModule = fn(requireModule, ...args);
const { IndexedDB } = indexedDBModule;
class MockedIndexedDB {
constructor() {
this.mockIndexedDB = {};
}
write(table, key, value) {
if (!(table in this.mockIndexedDB)) {
this.mockIndexedDB[table] = {};
}
this.mockIndexedDB[table][key] = value;
}
read(table, key) {
return Promise.resolve(this.mockIndexedDB[table]?.[key]);
}
invalidate(tables = null) {
if (tables) {
tables = typeof tables === "string" ? [tables] : tables;
for (const table of tables) {
if (table in this.mockIndexedDB) {
this.mockIndexedDB[table] = {};
}
}
} else {
this.mockIndexedDB = {};
}
}
}
return Object.assign(indexedDBModule, {
IndexedDB: MockedIndexedDB,
_OriginalIndexedDB: IndexedDB,
});
};
}

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,15 @@
declare module "mock_models" {
import { webModels } from "@web/../tests/web_test_helpers";
export interface IrModelFields extends webModels.IrModelFields {}
export interface IrModuleCategory extends webModels.IrModuleCategory {}
export interface ResGroups extends webModels.ResGroups {}
export interface ResGroupsPrivilege extends webModels.ResGroupsPrivilege {}
export interface Models {
"ir.model.fields": IrModelFields;
"ir.module.category": IrModuleCategory;
"res.groups": ResGroups;
"res.groups.privilege": ResGroupsPrivilege;
}
}

View file

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

View file

@ -0,0 +1,9 @@
import { ServerModel } from "../mock_model";
export class IrHttp extends ServerModel {
_name = "ir.http";
lazy_session_info() {
return {};
}
}

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,5 @@
import { ServerModel } from "../mock_model";
export class IrModuleCategory extends ServerModel {
_name = "ir.module.category";
}

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,30 @@
import { serverState } from "../../mock_server_state.hoot";
import { ServerModel } from "../mock_model";
import * as fields from "../mock_fields";
export class ResGroups extends ServerModel {
_name = "res.groups";
full_name = fields.Char({ compute: "_compute_full_name" });
_compute_full_name() {
for (const group of this) {
const privilegeName = group.privilege_id?.name;
const groupName = group.name;
const shortDisplayName = this.env.context?.short_display_name;
if (privilegeName && !shortDisplayName) {
group.full_name = `${privilegeName} / ${groupName}`;
} else {
group.full_name = groupName;
}
}
}
_records = [
{
id: serverState.groupId,
name: "Internal User",
privilege_id: false,
},
];
}

View file

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

View file

@ -0,0 +1,50 @@
import { serverState } from "../../mock_server_state.hoot";
import { ServerModel } from "../mock_model";
import * as fields from "../mock_fields";
export class ResPartner extends ServerModel {
_name = "res.partner";
main_user_id = fields.Many2one({ compute: "_compute_main_user_id", relation: "res.users" });
_compute_main_user_id() {
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
for (const partner of this) {
const users = ResUsers.browse(partner.user_ids);
const internalUsers = users.filter((user) => !user.share);
if (internalUsers.length > 0) {
partner.main_user_id = internalUsers[0].id;
} else if (users.length > 0) {
partner.main_user_id = users[0].id;
} else {
partner.main_user_id = false;
}
}
}
_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,
name: "OdooBot",
},
];
}

View file

@ -0,0 +1,53 @@
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",
},
{
id: serverState.odoobotUid,
active: false,
login: "odoobot",
partner_id: serverState.odoobotId,
password: "odoobot",
},
];
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;
}
}

View file

@ -0,0 +1,77 @@
import { ServerModel } from "../mock_model";
import { getKwArgs } from "../mock_server_utils";
import { ensureArray } from "@web/core/utils/arrays";
/**
* @template T
* @typedef {import("@web/../tests/web_test_helpers").KwArgs<T>} KwArgs
*/
const ORM_AUTOMATIC_FIELDS = new Set([
"create_date",
"create_uid",
"display_name",
"name",
"write_date",
"write_uid",
]);
export class ResUsersSettings extends ServerModel {
// TODO: merge this class with mail models
_name = "res.users.settings";
/** @param {number|number[]} userIdOrIds */
_find_or_create_for_user(userIdOrIds) {
const [userId] = ensureArray(userIdOrIds);
const settings = this._filter([["user_id", "=", userId]])[0];
if (settings) {
return settings;
}
const settingsId = this.create({ user_id: userId });
return this.browse(settingsId)[0];
}
/**
* @param {number} id
* @param {string[]} [fields_to_format]
*/
res_users_settings_format(id, fields_to_format) {
const kwargs = getKwArgs(arguments, "id", "fields_to_format");
id = kwargs.id;
delete kwargs.id;
fields_to_format = kwargs.fields_to_format;
const [settings] = this.browse(id);
const filterPredicate = fields_to_format
? ([fieldName]) => fields_to_format.includes(fieldName)
: ([fieldName]) => !ORM_AUTOMATIC_FIELDS.has(fieldName);
const res = Object.fromEntries(Object.entries(settings).filter(filterPredicate));
if (Reflect.ownKeys(res).includes("user_id")) {
res.user_id = { id: settings.user_id };
}
return res;
}
/**
* @param {number | Iterable<number>} idOrIds
* @param {Object} newSettings
* @param {KwArgs<{ new_settings }>} [kwargs]
*/
set_res_users_settings(idOrIds, new_settings) {
const kwargs = getKwArgs(arguments, "idOrIds", "new_settings");
idOrIds = kwargs.idOrIds;
delete kwargs.idOrIds;
new_settings = kwargs.new_settings || {};
const [id] = ensureArray(idOrIds);
const [oldSettings] = this.browse(id);
const changedSettings = {};
for (const setting in new_settings) {
if (setting in oldSettings && new_settings[setting] !== oldSettings[setting]) {
changedSettings[setting] = new_settings[setting];
}
}
this.write(id, changedSettings);
return changedSettings;
}
}

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 || 0,
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,163 @@
// ! 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",
currency_id: 1,
},
],
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,
odoobotUid: 518,
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,77 @@
// ! 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,
},
can_insert_in_spreadsheet: true,
db,
registry_hash: "05500d71e084497829aa807e3caa2e7e9782ff702c15b2f57f87f2d64d049bd0",
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,
},
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,748 @@
// ! 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 { mockIndexedDB } from "./mock_indexed_db.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 unmockedOrm("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);
};
}
/**
* @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
"Cropper", // Cropper.js
"DiffMatchPatch", // Diff Match Patch
"DOMPurify", // DOMPurify
"Diff2Html",
"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/utils/indexed_db", mockIndexedDB],
["@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 = "";
let lastSuiteName = null;
let lastNumberTests = 0;
for (const moduleName of testModuleNames) {
if (lastSuiteName) {
await __gcAndLogMemory(lastSuiteName, lastNumberTests);
lastSuiteName = null;
}
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();
lastSuiteName = suite.fullName;
lastNumberTests = suite.reporting.tests;
if (!running) {
break;
}
}
if (lastSuiteName) {
await __gcAndLogMemory(lastSuiteName, lastNumberTests);
}
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");
}
/**
* 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
*/
export async function unmockedOrm(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;
}

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,356 @@
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 .fa-search`).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 editFavorite(text) {
await ensureSearchBarMenu();
await contains(`.o_favorite_menu .o_menu_item:contains(/^${text}$/i) i.fa-pencil`, {
visible: false,
}).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_save_favorite`).click();
}
export async function saveAndEditFavorite() {
await ensureSearchBarMenu();
await contains(`.o_favorite_menu .o_edit_favorite`).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) {
if (getMockEnv().isSmall) {
await contains(".o_cp_switch_buttons .dropdown-toggle").click();
await contains(`.dropdown-item:contains(${viewType.toUpperCase()})`).click();
} else {
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,41 @@
import { after } from "@odoo/hoot";
import { serverState } from "./mock_server_state.hoot";
import { patchWithCleanup } from "./patch_test_helpers";
import {
loadLanguages,
translatedTerms,
translatedTermsGlobal,
translationLoaded,
} from "@web/core/l10n/translation";
/**
* @param {Record<string, string>} languages
*/
export function installLanguages(languages) {
serverState.multiLang = true;
patchWithCleanup(loadLanguages, {
installedLanguages: Object.entries(languages),
});
}
export function allowTranslations() {
translatedTerms[translationLoaded] = true;
after(() => {
translatedTerms[translationLoaded] = false;
});
}
/**
* @param {Record<string, Record<string, string>>} [terms]
*/
export function patchTranslations(terms = {}) {
allowTranslations();
for (const addonName in terms) {
if (!(addonName in translatedTerms)) {
patchWithCleanup(translatedTerms, { [addonName]: {} });
}
patchWithCleanup(translatedTerms[addonName], terms[addonName]);
patchWithCleanup(translatedTermsGlobal, terms[addonName]);
}
}

View file

@ -0,0 +1,363 @@
import { after, expect, getFixture } from "@odoo/hoot";
import {
click,
formatXml,
queryAll,
queryAllTexts,
queryFirst,
runAllTimers,
} 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 { getMockEnv, 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 {{
* value?: string;
* index?: number;
* }} EditSelectMenuParams
*
* @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) {
const selector = getMockEnv().isSmall
? `[name='${fieldName}'] input`
: `[name='${fieldName}'] .dropdown input`;
await contains(buildSelector(selector, options)).click();
}
/**
* @param {string} fieldName
* @param {string} itemContent
* @param {SelectorOptions} [options]
*/
export async function clickFieldDropdownItem(fieldName, itemContent, options) {
if (getMockEnv().isSmall) {
await contains(`.o_kanban_record:contains('${itemContent}')`).click();
return;
}
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);
}
/**
* Changes or clears the value in a SelectMenu component, supporting when
* the input is displayed in the toggler, in a Dropdown menu or in a
* BottomSheet as well. The helper can directly select a value if it's
* displayed or perform a search in the SelectMenu input if present.
* @param {string} selector
* @param {EditSelectMenuParams} [params]
*/
export async function editSelectMenu(selector, { value, index }) {
async function selectItem(value) {
const elementToSelect = queryFirst(`.o_select_menu_item:contains(${value})`);
if (elementToSelect) {
await click(elementToSelect);
return;
} else {
await contains(inputSelector).edit(value, { confirm: false });
await runAllTimers();
return selectItem(value);
}
}
let inputSelector = buildSelector(selector);
const selectMenuId = queryFirst(inputSelector).closest(".o_select_menu").dataset.id;
if (!queryFirst(`.o_select_menu_menu [data-id='${selectMenuId}']`)) {
await contains(inputSelector).click();
}
if (queryFirst(".o_select_menu_menu input")) {
inputSelector = ".o_select_menu_menu input";
await contains(inputSelector).click();
}
if (index !== undefined) {
return await contains(`.o_select_menu_item:nth-of-type(${index + 1})`).click();
}
if (value === "") {
// Because this helper must work even when no input is editable (searchable=false),
// we unselect the currently selected value with the 'X' button
const clearButton = queryFirst(
`.o_select_menu[data-id='${selectMenuId}'] .o_select_menu_toggler_clear, .o_select_menu_menu .o_clear_button`
);
if (clearButton) {
await click(clearButton);
} else {
await contains(inputSelector).edit("", { confirm: false });
queryFirst(inputSelector).dispatchEvent(new Event("blur"));
}
} else {
await selectItem(value);
}
await animationFrame();
}

View file

@ -0,0 +1,41 @@
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 = {}) {
const WebClientComponent = options.WebClient || WebClient;
delete options.WebClient;
const webClient = await mountWithCleanup(WebClientComponent, options);
// Wait for visual changes caused by a potential loadState
await animationFrame();
// wait for BlankComponent
await animationFrame();
// wait for the regular rendering
await animationFrame();
return webClient;
}

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,838 @@
import { expect, test } from "@odoo/hoot";
import {
Deferred,
animationFrame,
hover,
isInViewPort,
isScrollable,
pointerDown,
pointerUp,
press,
queryAllAttributes,
queryAllTexts,
queryFirst,
queryOne,
queryRect,
runAllTimers,
} from "@odoo/hoot-dom";
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;
}
function buildSources(generate, options = {}) {
return [
{
options: generate,
optionSlot: options.optionSlot,
},
];
}
function item(label, onSelect, data = {}) {
return {
data,
label,
onSelect() {
return onSelect?.(this);
},
};
}
test("can be rendered", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`<AutoComplete value="'Hello'" sources="sources"/>`;
static props = [];
sources = buildSources(() => [item("World"), item("Hello")]);
}
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"/>`;
static props = [];
state = useState({ value: "Hello" });
sources = buildSources(() => [
item("World", this.onSelect.bind(this)),
item("Hello", this.onSelect.bind(this)),
]);
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" resetOnSelect="true"/>
</div>
`;
static props = [];
state = useState({ value: "Hello" });
sources = buildSources(() => [
item("World", this.onSelect.bind(this)),
item("Hello", this.onSelect.bind(this)),
]);
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="sources"/>`;
static props = [];
sources = buildSources(() => [item("World"), item("Hello")]);
}
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="sources" autoSelect="true"/>`;
static props = [];
sources = buildSources(() => [item("World"), item("Hello")]);
}
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="sources"/>`;
static props = [];
sources = buildSources(() => [item("Bar")]);
}
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="sources" autoSelect="true"/>
</div>
</div>
`;
static props = [];
sources = buildSources(() => [item("World"), item("Hello")]);
}
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="sources"/>
</div>
</div>
`;
static props = [];
sources = buildSources(() => [item("World"), item("Hello")]);
}
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="sources" autoSelect="true"/>`;
static props = [];
sources = buildSources(() => [item("World"), item("Hello")]);
}
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="sources"/>`;
static props = [];
sources = buildSources(() => [item("World"), item("Hello")]);
}
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 () => {
const ITEMS = [item("AB"), item("AC"), item("BC")];
let def = new Deferred();
class Parent extends Component {
static components = { AutoComplete };
static template = xml`<AutoComplete value="''" sources="sources"/>`;
static props = [];
sources = buildSources(async (request) => {
await def;
return ITEMS.filter((option) => option.label.includes(request));
});
}
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"/>`;
static props = [];
sources = buildSources(() => []);
}
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"/>`;
static props = [];
sources = buildSources((request) =>
request.length > 2 ? [item("test A"), item("test B"), item("test C")] : []
);
}
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="sources" autofocus="true"/>`;
static props = [];
sources = buildSources(() => [item("World"), item("Hello")]);
}
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="sources"/>
`;
static props = [];
sources = buildSources(() => [item("My Selection", this.onSelect.bind(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="[]"/>
`;
static props = [];
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"
onBlur.bind="onBlur"
onChange.bind="onChange"
autoSelect="true"
/>
`;
static props = [];
state = useState({ value: "" });
sources = buildSources(() => [
item("World", this.onSelect.bind(this)),
item("Hello", this.onSelect.bind(this)),
]);
onBlur() {
expect.step("blur");
}
onChange() {
expect.step("change");
}
onSelect(option) {
this.state.value = option.label;
expect.step(`select ${option.label}`);
}
}
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" autoSelect="true"/>`;
static components = { AutoComplete };
static props = [];
state = useState({ value: "" });
sources = buildSources(() => [
item("World", this.onSelect.bind(this)),
item("Hello", this.onSelect.bind(this)),
]);
onSelect(option) {
this.state.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 () => {
const ITEMS = [item("World"), item("Hello")];
class Parent extends Component {
static template = xml`<AutoComplete value="state.value" sources="sources"/>`;
static components = { AutoComplete };
static props = [];
state = useState({ value: " World" });
sources = buildSources((request) => ITEMS.filter(({ label }) => label.startsWith(request)));
}
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"/>`;
static components = { AutoComplete };
static props = [];
state = useState({ value: "" });
sources = buildSources(() => [item("World"), item("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" autoSelect="true"/>
`;
static components = { AutoComplete };
static props = [];
state = useState({ value: "" });
sources = buildSources(() => [
item("Never"),
item("Gonna"),
item("Give"),
item("You"),
item("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,
});
});
test("source with option slot", async () => {
class Parent extends Component {
static template = xml`
<AutoComplete value="''" sources="sources">
<t t-set-slot="use_this_slot" t-slot-scope="scope">
<div class="slot_item">
<t t-esc="scope.data.id"/>: <t t-esc="scope.label"/>
</div>
</t>
</AutoComplete>
`;
static components = { AutoComplete };
static props = [];
sources = buildSources(
() => [item("Hello", () => {}, { id: 1 }), item("World", () => {}, { id: 2 })],
{ optionSlot: "use_this_slot" }
);
}
await mountWithCleanup(Parent);
await contains(`.o-autocomplete input`).click();
expect(queryAllTexts(`.o-autocomplete--dropdown-item .slot_item`)).toEqual([
"1: Hello",
"2: World",
]);
});
test("unselectable options are... not selectable", async () => {
class Parent extends Component {
static template = xml`
<AutoComplete value="''" sources="sources"/>
`;
static components = { AutoComplete };
static props = [];
sources = buildSources(() => [
{ label: "unselectable" },
item("selectable", this.onSelect.bind(this)),
{ label: "selectable" },
{ label: "unselectable" },
]);
onSelect(option) {
expect.step(`selected: ${option.label}`);
}
}
await mountWithCleanup(Parent);
await contains(`.o-autocomplete input`).click();
expect(`.o-autocomplete--input`).toHaveAttribute("aria-activedescendant", "autocomplete_0_1");
expect(`.dropdown-item#autocomplete_0_1`).toHaveText("selectable");
expect(`.dropdown-item#autocomplete_0_1`).toHaveAttribute("aria-selected", "true");
await press("arrowup");
await animationFrame();
expect(`.o-autocomplete--input`).not.toHaveAttribute("aria-activedescendant");
await press("arrowdown");
await animationFrame();
expect(`.o-autocomplete--input`).toHaveAttribute("aria-activedescendant", "autocomplete_0_1");
await press("arrowdown");
await animationFrame();
expect(`.o-autocomplete--input`).not.toHaveAttribute("aria-activedescendant");
await press("arrowup");
await animationFrame();
expect(`.o-autocomplete--input`).toHaveAttribute("aria-activedescendant", "autocomplete_0_1");
expect(`.o-autocomplete--input`).toBeFocused();
await contains(`.dropdown-item:eq(0)`).click();
expect(`.o-autocomplete--input`).toBeFocused();
expect.verifySteps([]);
await contains(`.dropdown-item:eq(2)`).click();
expect(`.o-autocomplete--input`).toBeFocused();
expect.verifySteps([]);
await contains(`.dropdown-item:eq(1)`).click();
expect(`.o-autocomplete--input`).toBeFocused();
expect.verifySteps(["selected: selectable"]);
});
test.tags("desktop");
test("items are selected only when the mouse moves, not just on enter", async () => {
class Parent extends Component {
static template = xml`<AutoComplete value="''" sources="sources"/>`;
static components = { AutoComplete };
static props = [];
sources = buildSources(() => [item("one"), item("two"), item("three")]);
}
// In this test we use custom events to prevent unwanted mouseenter/mousemove events
await mountWithCleanup(Parent);
queryOne(`.o-autocomplete input`).focus();
queryOne(`.o-autocomplete input`).click();
await animationFrame();
expect(".o-autocomplete--dropdown-item:nth-child(1) .dropdown-item").toHaveClass(
"ui-state-active"
);
await hover(".o-autocomplete--dropdown-item:nth-child(2)");
await animationFrame();
// mouseenter should be ignored
expect(".o-autocomplete--dropdown-item:nth-child(2) .dropdown-item").not.toHaveClass(
"ui-state-active"
);
await press("arrowdown");
await animationFrame();
expect(".o-autocomplete--dropdown-item:nth-child(2) .dropdown-item").toHaveClass(
"ui-state-active"
);
await hover(".o-autocomplete--dropdown-item:nth-child(3)");
await animationFrame();
expect(".o-autocomplete--dropdown-item:nth-child(2) .dropdown-item").not.toHaveClass(
"ui-state-active"
);
expect(".o-autocomplete--dropdown-item:nth-child(3) .dropdown-item").toHaveClass(
"ui-state-active"
);
});

View file

@ -1,572 +0,0 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { registry } from "@web/core/registry";
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { makeTestEnv } from "../helpers/mock_env";
import {
click,
editInput,
getFixture,
makeDeferred,
mount,
nextTick,
patchWithCleanup,
triggerEvent,
triggerEvents,
} from "../helpers/utils";
import { Component, useState, xml } from "@odoo/owl";
const serviceRegistry = registry.category("services");
let env;
let target;
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async () => {
serviceRegistry.add("hotkey", hotkeyService);
env = await makeTestEnv();
target = getFixture();
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
});
});
QUnit.module("AutoComplete");
QUnit.test("can be rendered", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
await mount(Parent, target, { env });
assert.containsOnce(target, ".o-autocomplete");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
await click(target, ".o-autocomplete--input");
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
const options = [...target.querySelectorAll(".o-autocomplete--dropdown-item")];
assert.deepEqual(
options.map((el) => el.textContent),
["World", "Hello"]
);
const optionItems = [...target.querySelectorAll(".dropdown-item")];
assert.deepEqual(
optionItems.map((el) => ({
id: el.id,
role: el.getAttribute("role"),
"aria-selected": el.getAttribute("aria-selected"),
})),
[
{ id: "autocomplete_0_0", role: "option", "aria-selected": "true" },
{ id: "autocomplete_0_1", role: "option", "aria-selected": "false" },
]
);
const input = target.querySelector(".o-autocomplete--input");
assert.strictEqual(input.getAttribute("aria-activedescendant"), optionItems[0].id);
});
QUnit.test("select option", async (assert) => {
class Parent extends Component {
setup() {
this.state = useState({
value: "Hello",
});
}
get sources() {
return [
{
options: [{ label: "World" }, { label: "Hello" }],
},
];
}
onSelect(option) {
this.state.value = option.label;
assert.step(option.label);
}
}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete
value="state.value"
sources="sources"
onSelect="(option) => this.onSelect(option)"
/>
`;
await mount(Parent, target, { env });
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
await click(target, ".o-autocomplete--input");
await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[0]);
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "World");
assert.verifySteps(["World"]);
await click(target, ".o-autocomplete--input");
await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[1]);
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
assert.verifySteps(["Hello"]);
});
QUnit.test("open dropdown on input", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
await mount(Parent, target, { env });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
await triggerEvent(target, ".o-autocomplete--input", "input");
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
});
QUnit.test("cancel result on escape keydown", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
autoSelect="true"
/>
`;
await mount(Parent, target, { env });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
await editInput(target, ".o-autocomplete--input", "H");
await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "Escape" });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
});
QUnit.test("scroll outside should cancel result", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
autoSelect="true"
/>
`;
await mount(Parent, target, { env });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
await click(target, ".o-autocomplete--input");
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
await editInput(target, ".o-autocomplete--input", "H");
await triggerEvent(target, null, "scroll");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
});
QUnit.test("scroll inside should keep dropdown open", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
await mount(Parent, target, { env });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
await click(target, ".o-autocomplete--input");
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
await triggerEvent(target, ".o-autocomplete--dropdown-menu", "scroll");
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
});
QUnit.test("losing focus should cancel result", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
autoSelect="true"
/>
`;
await mount(Parent, target, { env });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
await editInput(target, ".o-autocomplete--input", "H");
await triggerEvent(target, "", "pointerdown");
await triggerEvent(target, ".o-autocomplete--input", "blur");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
});
QUnit.test("click out after clearing input", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
await mount(Parent, target, { env });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
await editInput(target, ".o-autocomplete--input", "");
await triggerEvent(target, "", "pointerdown");
await triggerEvent(target, ".o-autocomplete--input", "blur");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "");
});
QUnit.test("open twice should not display previous results", async (assert) => {
let def = makeDeferred();
class Parent extends Component {
get sources() {
return [
{
async options(search) {
await def;
if (search === "A") {
return [{ label: "AB" }, { label: "AC" }];
}
return [{ label: "AB" }, { label: "AC" }, { label: "BC" }];
},
},
];
}
}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete value="''" sources="sources" onSelect="() => {}"/>
`;
await mount(Parent, target, { env });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
await triggerEvent(target, ".o-autocomplete--input", "click");
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
assert.containsOnce(target, ".o-autocomplete--dropdown-item");
assert.containsOnce(target, ".o-autocomplete--dropdown-item .fa-spin"); // loading
def.resolve();
await nextTick();
assert.containsN(target, ".o-autocomplete--dropdown-item", 3);
assert.containsNone(target, ".fa-spin");
def = makeDeferred();
target.querySelector(".o-autocomplete--input").value = "A";
await triggerEvent(target, ".o-autocomplete--input", "input");
assert.containsOnce(target, ".o-autocomplete--dropdown-item");
assert.containsOnce(target, ".o-autocomplete--dropdown-item .fa-spin"); // loading
def.resolve();
await nextTick();
assert.containsN(target, ".o-autocomplete--dropdown-item", 2);
assert.containsNone(target, ".fa-spin");
await click(target.querySelector(".o-autocomplete--dropdown-item"));
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
// re-open the dropdown -> should not display the previous results
def = makeDeferred();
await triggerEvent(target, ".o-autocomplete--input", "click");
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
assert.containsOnce(target, ".o-autocomplete--dropdown-item");
assert.containsOnce(target, ".o-autocomplete--dropdown-item .fa-spin"); // loading
});
QUnit.test("press enter on autocomplete with empty source", async (assert) => {
class Parent extends Component {
get sources() {
return [{ options: [] }];
}
onSelect() {}
}
Parent.components = { AutoComplete };
Parent.template = xml`<AutoComplete value="''" sources="sources" onSelect="onSelect"/>`;
await mount(Parent, target, { env });
assert.containsOnce(target, ".o-autocomplete--input");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
// click inside the input and press "enter", because why not
await click(target, ".o-autocomplete--input");
await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "Enter" });
assert.containsOnce(target, ".o-autocomplete--input");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
});
QUnit.test("press enter on autocomplete with empty source (2)", async (assert) => {
// 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 {
get sources() {
const options = (val) => {
if (val.length > 2) {
return [{ label: "test A" }, { label: "test B" }, { label: "test C" }];
}
return [];
};
return [{ options }];
}
onSelect() {}
}
Parent.components = { AutoComplete };
Parent.template = xml`<AutoComplete value="''" sources="sources" onSelect="onSelect"/>`;
await mount(Parent, target, { env });
assert.containsOnce(target, ".o-autocomplete--input");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "");
// click inside the input and press "enter", because why not
await editInput(target, ".o-autocomplete--input", "test");
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
assert.containsN(
target,
".o-autocomplete--dropdown-menu .o-autocomplete--dropdown-item",
3
);
await editInput(target, ".o-autocomplete--input", "t");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "Enter" });
assert.containsOnce(target, ".o-autocomplete--input");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "t");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
});
QUnit.test("correct sequence of blur, focus and select [REQUIRE FOCUS]", async (assert) => {
class Parent extends Component {
setup() {
this.state = useState({
value: "",
});
}
get sources() {
return [
{
options: [{ label: "World" }, { label: "Hello" }],
},
];
}
onChange() {
assert.step("change");
}
onSelect(option) {
target.querySelector(".o-autocomplete--input").value = option.label;
assert.step("select " + option.label);
}
onBlur() {
assert.step("blur");
}
}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete
value="state.value"
sources="sources"
onSelect.bind="onSelect"
onBlur.bind="onBlur"
onChange.bind="onChange"
autoSelect="true"
/>
`;
await mount(Parent, target, { env });
assert.containsOnce(target, ".o-autocomplete--input");
const input = target.querySelector(".o-autocomplete--input");
await click(input);
input.focus();
// Navigate suggestions using arrow keys
const optionItems = [...target.querySelectorAll(".dropdown-item")];
assert.deepEqual(
optionItems.map((el) => ({
id: el.id,
role: el.getAttribute("role"),
"aria-selected": el.getAttribute("aria-selected"),
})),
[
{ id: "autocomplete_0_0", role: "option", "aria-selected": "true" },
{ id: "autocomplete_0_1", role: "option", "aria-selected": "false" },
]
);
assert.strictEqual(input.getAttribute("aria-activedescendant"), optionItems[0].id);
await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "arrowdown" });
assert.deepEqual(
optionItems.map((el) => ({
id: el.id,
role: el.getAttribute("role"),
"aria-selected": el.getAttribute("aria-selected"),
})),
[
{ id: "autocomplete_0_0", role: "option", "aria-selected": "false" },
{ id: "autocomplete_0_1", role: "option", "aria-selected": "true" },
]
);
assert.strictEqual(input.getAttribute("aria-activedescendant"), optionItems[1].id);
// Start typing hello and click on the result
await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "h" });
input.value = "h";
await triggerEvent(input, "", "input");
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
const pointerdownEvent = await triggerEvent(
target.querySelectorAll(".o-autocomplete--dropdown-item")[1],
"",
"pointerdown"
);
assert.strictEqual(pointerdownEvent.defaultPrevented, false);
const mousedownEvent = await triggerEvent(
target.querySelectorAll(".o-autocomplete--dropdown-item")[1],
"",
"mousedown"
);
assert.strictEqual(mousedownEvent.defaultPrevented, false);
await triggerEvent(input, "", "change");
await triggerEvent(input, "", "blur");
await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[1], "");
assert.verifySteps(["change", "select Hello"]);
assert.strictEqual(input, document.activeElement);
// Clear input and focus out
await triggerEvent(input, "", "keydown", { key: "Backspace" });
input.value = "";
await triggerEvent(input, "", "input");
await triggerEvent(target, "", "pointerdown");
await triggerEvent(input, "", "change");
input.blur();
await click(target, "");
assert.verifySteps(["change", "blur"]);
});
QUnit.test("autocomplete always closes on click away [REQUIRE FOCUS]", async (assert) => {
class Parent extends Component {
setup() {
this.state = useState({
value: "",
});
}
get sources() {
return [
{
options: [{ label: "World" }, { label: "Hello" }],
},
];
}
onSelect(option) {
target.querySelector(".o-autocomplete--input").value = option.label;
}
}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete
value="state.value"
sources="sources"
onSelect.bind="onSelect"
autoSelect="true"
/>
`;
await mount(Parent, target, { env });
assert.containsOnce(target, ".o-autocomplete--input");
const input = target.querySelector(".o-autocomplete--input");
await click(input);
assert.containsN(target, ".o-autocomplete--dropdown-item", 2);
const pointerdownEvent = await triggerEvent(
target.querySelectorAll(".o-autocomplete--dropdown-item")[1],
"",
"pointerdown"
);
assert.strictEqual(pointerdownEvent.defaultPrevented, false);
const mousedownEvent = await triggerEvent(
target.querySelectorAll(".o-autocomplete--dropdown-item")[1],
"",
"mousedown"
);
assert.strictEqual(mousedownEvent.defaultPrevented, false);
await triggerEvent(input, "", "blur");
await triggerEvent(target, "", "pointerup");
await triggerEvent(target, "", "mouseup");
assert.containsN(target, ".o-autocomplete--dropdown-item", 2);
await triggerEvent(target, "", "pointerdown");
assert.containsNone(target, ".o-autocomplete--dropdown-item");
});
QUnit.test("autocomplete trim spaces for search", async (assert) => {
class Parent extends Component {
setup() {
this.state = useState({
value: " World",
});
}
get sources() {
return [
{
options(search) {
return [{ label: "World" }, { label: "Hello" }].filter(({ label }) =>
label.startsWith(search)
);
},
},
];
}
}
Parent.template = xml`
<AutoComplete value="state.value" sources="sources" onSelect="() => {}"/>
`;
Parent.props = ["*"];
Parent.components = { AutoComplete };
await mount(Parent, target, { env });
await click(target, `.o-autocomplete input`);
assert.deepEqual(
[...target.querySelectorAll(`.o-autocomplete--dropdown-item`)].map(
(el) => el.textContent
),
["World", "Hello"]
);
});
});

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

@ -1,71 +0,0 @@
/** @odoo-module **/
import { titleService } from "@web/core/browser/title_service";
import { registry } from "@web/core/registry";
import { makeTestEnv } from "../../helpers/mock_env";
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
let env;
let title;
QUnit.module("Title", {
async beforeEach() {
title = document.title;
registry.category("services").add("title", titleService);
env = await makeTestEnv();
},
afterEach() {
document.title = title;
},
});
QUnit.test("simple title", async (assert) => {
assert.expect(1);
env.services.title.setParts({ zopenerp: "Odoo" });
assert.strictEqual(env.services.title.current, "Odoo");
});
QUnit.test("add title part", async (assert) => {
assert.expect(2);
env.services.title.setParts({ zopenerp: "Odoo", chat: null });
assert.strictEqual(env.services.title.current, "Odoo");
env.services.title.setParts({ action: "Import" });
assert.strictEqual(env.services.title.current, "Odoo - Import");
});
QUnit.test("modify title part", async (assert) => {
assert.expect(2);
env.services.title.setParts({ zopenerp: "Odoo" });
assert.strictEqual(env.services.title.current, "Odoo");
env.services.title.setParts({ zopenerp: "Zopenerp" });
assert.strictEqual(env.services.title.current, "Zopenerp");
});
QUnit.test("delete title part", async (assert) => {
assert.expect(2);
env.services.title.setParts({ zopenerp: "Odoo" });
assert.strictEqual(env.services.title.current, "Odoo");
env.services.title.setParts({ zopenerp: null });
assert.strictEqual(env.services.title.current, "");
});
QUnit.test("all at once", async (assert) => {
assert.expect(2);
env.services.title.setParts({ zopenerp: "Odoo", action: "Import" });
assert.strictEqual(env.services.title.current, "Odoo - Import");
env.services.title.setParts({ action: null, zopenerp: "Zopenerp", chat: "Sauron" });
assert.strictEqual(env.services.title.current, "Zopenerp - Sauron");
});
QUnit.test("get title parts", async (assert) => {
assert.expect(3);
env.services.title.setParts({ zopenerp: "Odoo", action: "Import" });
assert.strictEqual(env.services.title.current, "Odoo - Import");
const parts = env.services.title.getParts();
assert.deepEqual(parts, { zopenerp: "Odoo", action: "Import" });
parts.action = "Export";
assert.strictEqual(env.services.title.current, "Odoo - 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,148 @@
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`<div t-translation-context="web"><CheckBox>ragabadabadaba</CheckBox></div>`;
}
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"]);
});
test("checkbox with props indeterminate", async () => {
class Parent extends Component {
static components = { CheckBox };
static props = {};
static template = xml`<CheckBox indeterminate="true" />`;
}
await mountWithCleanup(Parent);
expect(`.o-checkbox input`).toHaveCount(1);
expect(`.o-checkbox input`).toBeChecked({ indeterminate: true });
});

View file

@ -1,178 +0,0 @@
/** @odoo-module **/
import { CheckBox } from "@web/core/checkbox/checkbox";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { translatedTerms } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import {
click,
getFixture,
patchWithCleanup,
mount,
triggerEvent,
triggerHotkey,
nextTick,
} from "@web/../tests/helpers/utils";
import { Component, useState, xml } from "@odoo/owl";
const serviceRegistry = registry.category("services");
let target;
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async () => {
target = getFixture();
serviceRegistry.add("hotkey", hotkeyService);
});
QUnit.module("CheckBox");
QUnit.test("can be rendered", async (assert) => {
const env = await makeTestEnv();
await mount(CheckBox, target, { env, props: {} });
assert.containsOnce(target, '.o-checkbox input[type="checkbox"]');
});
QUnit.test("has a slot for translatable text", async (assert) => {
patchWithCleanup(translatedTerms, { ragabadabadaba: "rugubudubudubu" });
serviceRegistry.add("localization", makeFakeLocalizationService());
const env = await makeTestEnv();
class Parent extends Component {}
Parent.template = xml`<CheckBox>ragabadabadaba</CheckBox>`;
Parent.components = { CheckBox };
await mount(Parent, target, { env });
assert.containsOnce(target, "div.form-check");
assert.strictEqual(target.querySelector("div.form-check").textContent, "rugubudubudubu");
});
QUnit.test("call onChange prop when some change occurs", async (assert) => {
const env = await makeTestEnv();
let value = false;
class Parent extends Component {
onChange(checked) {
value = checked;
}
}
Parent.template = xml`<CheckBox onChange="onChange"/>`;
Parent.components = { CheckBox };
await mount(Parent, target, { env });
assert.containsOnce(target, ".o-checkbox input");
await click(target.querySelector("input"));
assert.strictEqual(value, true);
await click(target.querySelector("input"));
assert.strictEqual(value, false);
});
QUnit.test("does not call onChange prop when disabled", async (assert) => {
const env = await makeTestEnv();
let onChangeCalled = false;
class Parent extends Component {
onChange(checked) {
onChangeCalled = true;
}
}
Parent.template = xml`<CheckBox onChange="onChange" disabled="true"/>`;
Parent.components = { CheckBox };
await mount(Parent, target, { env });
assert.containsOnce(target, ".o-checkbox input");
await click(target.querySelector("input"));
assert.strictEqual(onChangeCalled, false);
});
QUnit.test("can toggle value by pressing ENTER", async (assert) => {
const env = await makeTestEnv();
class Parent extends Component {
setup() {
this.state = useState({ value: false });
}
onChange(checked) {
this.state.value = checked;
}
}
Parent.template = xml`<CheckBox onChange.bind="onChange" value="state.value"/>`;
Parent.components = { CheckBox };
await mount(Parent, target, { env });
assert.containsOnce(target, ".o-checkbox input");
assert.notOk(target.querySelector(".o-checkbox input").checked);
await triggerEvent(target, ".o-checkbox input", "keydown", { key: "Enter" });
assert.ok(target.querySelector(".o-checkbox input").checked);
await triggerEvent(target, ".o-checkbox input", "keydown", { key: "Enter" });
assert.notOk(target.querySelector(".o-checkbox input").checked);
});
QUnit.test("toggling through multiple ways", async (assert) => {
const env = await makeTestEnv();
class Parent extends Component {
setup() {
this.state = useState({ value: false });
}
onChange(checked) {
this.state.value = checked;
assert.step(`${checked}`);
}
}
Parent.template = xml`<CheckBox onChange.bind="onChange" value="state.value"/>`;
Parent.components = { CheckBox };
await mount(Parent, target, { env });
assert.containsOnce(target, ".o-checkbox input");
assert.notOk(target.querySelector(".o-checkbox input").checked);
// Click on div
assert.verifySteps([]);
await click(target, ".o-checkbox");
assert.ok(target.querySelector(".o-checkbox input").checked);
assert.verifySteps(["true"]);
// Click on label
assert.verifySteps([]);
await click(target, ".o-checkbox > .form-check-label", true);
assert.notOk(target.querySelector(".o-checkbox input").checked);
assert.verifySteps(["false"]);
// Click on input (only possible programmatically)
assert.verifySteps([]);
await click(target, ".o-checkbox input");
assert.ok(target.querySelector(".o-checkbox input").checked);
assert.verifySteps(["true"]);
// When somehow applying focus on label, the focus receives it
// (this is the default behavior from the label)
target.querySelector(".o-checkbox > .form-check-label").focus();
await nextTick();
assert.strictEqual(document.activeElement, target.querySelector(".o-checkbox input"));
// Press Enter when focus is on input
assert.verifySteps([]);
triggerHotkey("Enter");
await nextTick();
assert.notOk(target.querySelector(".o-checkbox input").checked);
assert.verifySteps(["false"]);
// Pressing Space when focus is on the input is a standard behavior
// So we simulate it and verify that it will have its standard behavior.
assert.strictEqual(document.activeElement, target.querySelector(".o-checkbox input"));
const event = triggerEvent(
document.activeElement,
null,
"keydown",
{ key: "Space" },
{ fast: true }
);
assert.ok(!event.defaultPrevented);
target.querySelector(".o-checkbox input").checked = true;
assert.verifySteps([]);
triggerEvent(target, ".o-checkbox input", "change", {}, { fast: true });
await nextTick();
assert.ok(target.querySelector(".o-checkbox input").checked);
assert.verifySteps(["true"]);
});
});

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,271 @@
import { test, expect } from "@odoo/hoot";
import { press, click, animationFrame, queryOne } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { defineStyle, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { ColorPicker, DEFAULT_COLORS } from "@web/core/color_picker/color_picker";
import { CustomColorPicker } from "@web/core/color_picker/custom_color_picker/custom_color_picker";
import { registry } from "@web/core/registry";
test("basic rendering", async () => {
await mountWithCleanup(ColorPicker, {
props: {
state: {
selectedColor: "",
defaultTab: "",
},
getUsedCustomColors: () => [],
applyColor() {},
applyColorPreview() {},
applyColorResetPreview() {},
colorPrefix: "",
},
});
expect(".o_font_color_selector").toHaveCount(1);
expect(".o_font_color_selector .btn-tab").toHaveCount(2);
expect(".o_font_color_selector .btn.fa-trash").toHaveCount(1);
expect(".o_font_color_selector .o_colorpicker_section").toHaveCount(1);
expect(".o_font_color_selector .o_colorpicker_section .o_color_button").toHaveCount(5);
expect(".o_font_color_selector .o_color_section .o_color_button[data-color]").toHaveCount(
DEFAULT_COLORS.flat().length
);
});
test("basic rendering with selected color", async () => {
await mountWithCleanup(ColorPicker, {
props: {
state: {
selectedColor: "#B5D6A5",
defaultTab: "",
},
getUsedCustomColors: () => [],
applyColor() {},
applyColorPreview() {},
applyColorResetPreview() {},
colorPrefix: "",
},
});
expect(".o_font_color_selector").toHaveCount(1);
expect(".o_font_color_selector .o_color_section .o_color_button[data-color]").toHaveCount(
DEFAULT_COLORS.flat().length
);
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color='#B5D6A5'].selected"
).toHaveCount(1);
});
test("keyboard navigation", async () => {
await mountWithCleanup(ColorPicker, {
props: {
state: {
selectedColor: "",
defaultTab: "",
},
getUsedCustomColors: () => [],
applyColor() {},
applyColorPreview() {},
applyColorResetPreview() {},
colorPrefix: "",
},
});
// select the first color
await click(
".o_font_color_selector .o_color_section .o_color_button[data-color]:first-of-type"
);
await animationFrame();
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color]:first-of-type"
).toBeFocused();
// move to the second color
await press("arrowright");
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(2)"
).toBeFocused();
// select the second color using Enter key
await press("enter");
await animationFrame();
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(2)"
).toHaveClass("selected");
// move back to the first color
await press("arrowleft");
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color]:first-of-type"
).toBeFocused();
// cannot move if no previous color
await press("arrowleft");
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color]:first-of-type"
).toBeFocused();
// move the color below
await press("arrowdown");
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(9)"
).toBeFocused();
// move back to the first color
await press("arrowup");
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color]:first-of-type"
).toBeFocused();
// select the last color of the first row
await click(
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(8)"
);
await animationFrame();
// move to the first color of the second row
await press("arrowright");
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(9)"
).toBeFocused();
// move back to the last color of the first row
await press("arrowleft");
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(8)"
).toBeFocused();
// select the last color
await click(".o_font_color_selector .o_color_section .o_color_button[data-color]:last-of-type");
await animationFrame();
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color]:last-of-type"
).toBeFocused();
// cannot move if no next color
await press("arrowright");
expect(
".o_font_color_selector .o_color_section .o_color_button[data-color]:last-of-type"
).toBeFocused();
});
test("colorpicker inside the builder are linked to the builder theme colors", async () => {
await mountWithCleanup(ColorPicker, {
props: {
state: {
selectedColor: "",
defaultTab: "",
},
getUsedCustomColors: () => [],
applyColor() {},
applyColorPreview() {},
applyColorResetPreview() {},
colorPrefix: "",
cssVarColorPrefix: "xyz-",
},
});
const getButtonColor = (sel) => getComputedStyle(queryOne(sel)).backgroundColor;
defineStyle(`
:root {
--o-color-1: rgb(113, 75, 103);
--o-color-2: rgb(45, 49, 66);
--xyz-o-color-1: rgb(113, 75, 103);
--xyz-o-color-2: rgb(45, 49, 66);
}
`);
expect(getButtonColor("button[data-color='o-color-1']")).toBe("rgb(113, 75, 103)");
expect(getButtonColor("button[data-color='o-color-2']")).toBe("rgb(45, 49, 66)");
defineStyle(`
:root {
--xyz-o-color-1: rgb(0, 0, 255);
--xyz-o-color-2: rgb(0, 255, 0);
}
`);
expect(getButtonColor("button[data-color='o-color-1']")).toBe("rgb(0, 0, 255)");
expect(getButtonColor("button[data-color='o-color-2']")).toBe("rgb(0, 255, 0)");
});
test("colorpicker outside the builder are not linked to the builder theme colors", async () => {
await mountWithCleanup(ColorPicker, {
props: {
state: {
selectedColor: "",
defaultTab: "",
},
getUsedCustomColors: () => [],
applyColor() {},
applyColorPreview() {},
applyColorResetPreview() {},
colorPrefix: "",
cssVarColorPrefix: "",
},
});
const getButtonColor = (sel) => getComputedStyle(queryOne(sel)).backgroundColor;
defineStyle(`
:root {
--o-color-1: rgb(113, 75, 103);
--o-color-2: rgb(45, 49, 66);
--xyz-o-color-1: rgb(113, 75, 103);
--xyz-o-color-2: rgb(45, 49, 66);
}
`);
expect(getButtonColor("button[data-color='o-color-1']")).toBe("rgb(113, 75, 103)");
expect(getButtonColor("button[data-color='o-color-2']")).toBe("rgb(45, 49, 66)");
defineStyle(`
:root {
--xyz-o-color-1: rgb(0, 0, 255);
--xyz-o-color-2: rgb(0, 255, 0);
}
`);
expect(getButtonColor("button[data-color='o-color-1']")).toBe("rgb(113, 75, 103)");
expect(getButtonColor("button[data-color='o-color-2']")).toBe("rgb(45, 49, 66)");
});
test("custom color picker sets default color as selected", async () => {
await mountWithCleanup(CustomColorPicker, {
props: {
defaultColor: "#FF0000",
},
});
expect("input.o_hex_input").toHaveValue("#FF0000");
});
test("custom color picker change color on click in hue slider", async () => {
await mountWithCleanup(CustomColorPicker, { props: { selectedColor: "#FF0000" } });
expect("input.o_hex_input").toHaveValue("#FF0000");
await click(".o_color_slider");
expect("input.o_hex_input").not.toHaveValue("#FF0000");
});
class ExtraTab extends Component {
static template = xml`<p>Color picker extra tab</p>`;
static props = ["*"];
}
test("can register an extra tab", async () => {
registry.category("color_picker_tabs").add("web.extra", {
id: "extra",
name: "Extra",
component: ExtraTab,
});
await mountWithCleanup(ColorPicker, {
props: {
state: {
selectedColor: "#FF0000",
defaultTab: "",
},
getUsedCustomColors: () => [],
applyColor() {},
applyColorPreview() {},
applyColorResetPreview() {},
colorPrefix: "",
enabledTabs: ["solid", "custom", "extra"],
},
});
expect(".o_font_color_selector .btn-tab").toHaveCount(3);
await click("button.extra-tab");
await animationFrame();
expect("button.extra-tab").toHaveClass("active");
expect(".o_font_color_selector>p:last-child").toHaveText("Color picker extra tab");
registry.category("color_picker_tabs").remove("web.extra");
});

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"]);
});

View file

@ -1,123 +0,0 @@
/** @odoo-module **/
import { ColorList } from "@web/core/colorlist/colorlist";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { makeTestEnv } from "../helpers/mock_env";
import { click, getFixture, mount } from "../helpers/utils";
import { Component, xml } from "@odoo/owl";
const serviceRegistry = registry.category("services");
let target;
/**
* @param {typeof ColorList} Picker
* @param {Object} props
* @returns {Promise<ColorList>}
*/
async function mountComponent(Picker, props) {
serviceRegistry.add("ui", uiService);
target = getFixture();
class Parent extends Component {}
Parent.template = xml/* xml */ `
<t t-component="props.Picker" t-props="props.props"/>
<div class="outsideDiv">Outside div</div>
`;
const env = await makeTestEnv();
if (!props.onColorSelected) {
props.onColorSelected = () => {};
}
const parent = await mount(Parent, target, { env, props: { Picker, props } });
return parent;
}
QUnit.module("Components", () => {
QUnit.module("ColorList");
QUnit.test("basic rendering with forceExpanded props", async function (assert) {
await mountComponent(ColorList, {
colors: [0, 9],
forceExpanded: true,
});
assert.containsOnce(target, ".o_colorlist");
assert.containsN(target, ".o_colorlist button", 2, "two buttons are available");
const secondBtn = target.querySelectorAll(".o_colorlist button")[1];
assert.strictEqual(
secondBtn.attributes.title.value,
"Fuchsia",
"second button color is Fuchsia"
);
assert.hasClass(
secondBtn,
"o_colorlist_item_color_9",
"second button has the corresponding class"
);
});
QUnit.test(
"color click does not open the list if canToggle props is not given",
async function (assert) {
const selectedColorId = 0;
await mountComponent(ColorList, {
colors: [4, 5, 6],
selectedColor: selectedColorId,
onColorSelected: (colorId) => assert.step("color #" + colorId + " is selected"),
});
assert.containsOnce(target, ".o_colorlist");
assert.containsOnce(
target,
"button.o_colorlist_toggler",
"only the toggler button is available"
);
await click(target.querySelector(".o_colorlist button"));
assert.containsOnce(target, "button.o_colorlist_toggler", "button is still visible");
}
);
QUnit.test("open the list of colors if canToggle props is given", async function (assert) {
const selectedColorId = 0;
await mountComponent(ColorList, {
canToggle: true,
colors: [4, 5, 6],
selectedColor: selectedColorId,
onColorSelected: (colorId) => assert.step("color #" + colorId + " is selected"),
});
assert.containsOnce(target, ".o_colorlist");
assert.hasClass(
target.querySelector(".o_colorlist button"),
"o_colorlist_item_color_" + selectedColorId,
"toggler has the right class"
);
await click(target.querySelector(".o_colorlist button"));
assert.containsNone(
target,
"button.o_colorlist_toggler",
"toggler button is no longer visible"
);
assert.containsN(target, ".o_colorlist button", 3, "three buttons are available");
await click(target.querySelector(".outsideDiv"));
assert.containsOnce(target, ".o_colorlist button", "only one button is available");
assert.containsOnce(
target,
"button.o_colorlist_toggler",
"colorlist has been closed and toggler is visible"
);
// reopen the colorlist and select a color
await click(target.querySelector(".o_colorlist_toggler"));
await click(target.querySelectorAll(".o_colorlist button")[2]);
assert.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

@ -1,166 +0,0 @@
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { createWebClient, getActionManagerServerData } from "@web/../tests/webclient/helpers";
import { browser } from "@web/core/browser/browser";
import { Dialog } from "@web/core/dialog/dialog";
import { registry } from "@web/core/registry";
import { click, getFixture, nextTick, patchWithCleanup, triggerHotkey } from "../../helpers/utils";
import { editSearchBar } from "./command_service_tests";
let serverData;
let target;
QUnit.module("Menu Command Provider", {
async beforeEach() {
patchWithCleanup(browser, {
clearTimeout: () => {},
setTimeout: (later) => {
later();
},
});
const commandCategoryRegistry = registry.category("command_categories");
commandCategoryRegistry.add("apps", { namespace: "/" }, { sequence: 10 });
commandCategoryRegistry.add("menu_items", { namespace: "/" }, { sequence: 20 });
serverData = getActionManagerServerData();
serverData.menus = {
root: { id: "root", children: [0, 1, 2], name: "root", appID: "root" },
0: { id: 0, children: [], name: "UglyHack", appID: 0, xmlid: "menu_0" },
1: { id: 1, children: [], name: "Contact", appID: 1, actionID: 1001, xmlid: "menu_1" },
2: {
id: 2,
children: [3, 4],
name: "Sales",
appID: 2,
actionID: 1002,
xmlid: "menu_2",
},
3: {
id: 3,
children: [],
name: "Info",
appID: 2,
actionID: 1003,
xmlid: "menu_3",
},
4: {
id: 4,
children: [],
name: "Report",
appID: 2,
actionID: 1004,
xmlid: "menu_4",
},
};
serverData.actions[1003] = {
id: 1003,
tag: "__test__client__action__",
target: "main",
type: "ir.actions.client",
params: { description: "Info" },
};
serverData.actions[1004] = {
id: 1004,
tag: "__test__client__action__",
target: "main",
type: "ir.actions.client",
params: { description: "Report" },
};
target = getFixture();
},
afterEach() {},
});
QUnit.test("displays only apps if the search value is '/'", async (assert) => {
await createWebClient({ serverData });
assert.containsNone(target, ".o_menu_brand");
triggerHotkey("control+k");
await nextTick();
await editSearchBar("/");
assert.containsOnce(target, ".o_command_palette");
assert.containsOnce(target, ".o_command_category");
assert.containsN(target, ".o_command", 2);
assert.deepEqual(
[...target.querySelectorAll(".o_command_name")].map((el) => el.textContent),
["Contact", "Sales"]
);
});
QUnit.test("displays apps and menu items if the search value is not only '/'", async (assert) => {
await createWebClient({ serverData });
triggerHotkey("control+k");
await nextTick();
await editSearchBar("/sal");
assert.containsOnce(target, ".o_command_palette");
assert.containsN(target, ".o_command", 3);
assert.deepEqual(
[...target.querySelectorAll(".o_command_name")].map((el) => el.textContent),
["Sales", "Sales / Info", "Sales / Report"]
);
});
QUnit.test("opens an app", async (assert) => {
await createWebClient({ serverData });
assert.containsNone(target, ".o_menu_brand");
triggerHotkey("control+k");
await nextTick();
await editSearchBar("/");
assert.containsOnce(target, ".o_command_palette");
triggerHotkey("enter");
await nextTick();
await nextTick();
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "Contact");
assert.strictEqual(
target.querySelector(".test_client_action").textContent,
" ClientAction_Id 1"
);
});
QUnit.test("opens a menu items", async (assert) => {
await createWebClient({ serverData });
assert.containsNone(target, ".o_menu_brand");
triggerHotkey("control+k");
await nextTick();
await editSearchBar("/sal");
assert.containsOnce(target, ".o_command_palette");
assert.containsN(target, ".o_command_category", 2);
click(target, "#o_command_2");
await nextTick();
await nextTick();
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "Sales");
assert.strictEqual(
target.querySelector(".test_client_action").textContent,
" ClientAction_Report"
);
});
QUnit.test("open a menu item when a dialog is displayed", async (assert) => {
class CustomDialog extends Component {}
CustomDialog.components = { Dialog };
CustomDialog.template = xml`<Dialog contentClass="'test'">content</Dialog>`;
const webclient = await createWebClient({ serverData });
assert.containsNone(target, ".o_menu_brand");
assert.containsNone(target, ".modal .test");
webclient.env.services.dialog.add(CustomDialog);
await nextTick();
assert.containsOnce(target, ".modal .test");
triggerHotkey("control+k");
await nextTick();
await editSearchBar("/sal");
assert.containsOnce(target, ".o_command_palette");
assert.containsOnce(target, ".modal .test");
await click(target, "#o_command_2");
await nextTick();
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "Sales");
assert.containsNone(target, ".modal .test");
});

View file

@ -0,0 +1,52 @@
import { CopyButton } from "@web/core/copy_button/copy_button";
import { browser } from "@web/core/browser/browser";
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { beforeEach, expect, test } from "@odoo/hoot";
import { click } from "@odoo/hoot-dom";
beforeEach(() => {
patchWithCleanup(browser.navigator.clipboard, {
async writeText(text) {
expect.step(`writeText: ${text}`);
},
async write(object) {
expect.step(
`write: {${Object.entries(object)
.map(([k, v]) => k + ": " + v)
.join(", ")}}`
);
},
});
});
test("copies a string to the clipboard", async () => {
await mountWithCleanup(CopyButton, { props: { content: "content to copy" } });
await click(".o_clipboard_button");
expect.verifySteps(["writeText: content to copy"]);
});
test("copies an object to the clipboard", async () => {
await mountWithCleanup(CopyButton, { props: { content: { oneKey: "oneValue" } } });
await click(".o_clipboard_button");
expect.verifySteps(["write: {oneKey: oneValue}"]);
});
test("copies a string via a function to the clipboard", async () => {
let contentToCopy = "content to copy 1";
const content = () => contentToCopy;
await mountWithCleanup(CopyButton, { props: { content } });
await click(".o_clipboard_button");
contentToCopy = "content to copy 2";
await click(".o_clipboard_button");
expect.verifySteps(["writeText: content to copy 1", "writeText: content to copy 2"]);
});
test("copies an object via a function to the clipboard", async () => {
let contentToCopy = { oneKey: "oneValue" };
const content = () => contentToCopy;
await mountWithCleanup(CopyButton, { props: { content } });
await click(".o_clipboard_button");
contentToCopy = { anotherKey: "anotherValue" };
await click(".o_clipboard_button");
expect.verifySteps(["write: {oneKey: oneValue}", "write: {anotherKey: anotherValue}"]);
});

View file

@ -0,0 +1,145 @@
import { expect, test } 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 { DateTimeInput } from "@web/core/datetime/datetime_input";
import { useDateTimePicker } from "@web/core/datetime/datetime_picker_hook";
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"]);
});

View file

@ -0,0 +1,587 @@
import { test, expect, describe } from "@odoo/hoot";
import { Component, xml } from "@odoo/owl";
import {
assertDateTimePicker,
editTime,
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, queryFirst } 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",
class: "custom_class",
},
});
expect(".o_datetime_input").toHaveCount(1);
assertDateTimePicker(false);
expect(".o_datetime_input").toHaveValue("09/01/1997");
expect(".o_datetime_input").toHaveClass("custom_class");
await click(".o_datetime_input");
await animationFrame();
assertDateTimePicker({
title: "January 1997",
date: [
{
cells: [
[29, 30, 31, 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, 1],
[2, 3, 4, 5, 6, 7, 8],
],
daysOfWeek: ["", "S", "M", "T", "W", "T", "F", "S"],
weekNumbers: [1, 2, 3, 4, 5, 6],
},
],
});
});
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", true)).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: [
[29, 30, 31, 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, 1],
[2, 3, 4, 5, 6, 7, 8],
],
daysOfWeek: ["", "S", "M", "T", "W", "T", "F", "S"],
weekNumbers: [1, 2, 3, 4, 5, 6],
},
],
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
await editTime("15:45");
expect(".o_datetime_input").toHaveValue("08/02/1997 15:45:01");
expect.verifySteps(["1997-02-08 12: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",
onChange: (date) => expect.step(date.toSQL().split(".")[0]),
},
});
expect(".o_datetime_input").toHaveValue("09 janv., 1997 12:30");
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
await editTime("15:45");
expect(".o_datetime_input").toHaveValue("01 sept., 1997 15:45");
expect.verifySteps(["1997-09-01 12: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", "dd/MM/yyyy HH:mm"),
type: "datetime",
onChange: (date) => expect.step(date.toSQL().split(".")[0]),
},
});
expect(".o_datetime_input").toHaveValue("09/01/1997 08:30:00");
await contains(".o_datetime_input").click();
await editTime("8:15");
await click(document.body);
await animationFrame();
expect.verifySteps(["1997-01-09 08:15:00"]);
});
test("enter a datetime value", async () => {
expect.assertions(6);
await mountWithCleanup(DateTimeInputComp, {
props: {
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
format: "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", true)).toHaveClass("o_selected");
expect(".o_time_picker_input").toHaveValue("15: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(5);
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"]);
});
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")).toBe("08/02/1997 15:45", {
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");
await animationFrame();
await click(document.body);
await animationFrame();
expect.verifySteps(["datetime-changed"]);
expect(".o_datetime_input").toHaveValue("08/02/1997 15:45:00");
});
test("Clicking clear 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("Clicking apply 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-primary").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, {
props: { rounding: 0 },
});
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,418 @@
import { Component, useState, xml } from "@odoo/owl";
import { beforeEach, expect, test } from "@odoo/hoot";
import { click, queryAllTexts, edit, press, animationFrame, runAllTimers } from "@odoo/hoot-dom";
import { mockDate } from "@odoo/hoot-mock";
import { mountWithCleanup, defineParams } from "@web/../tests/web_test_helpers";
import { TimePicker } from "@web/core/time_picker/time_picker";
import { Dropdown } from "@web/core/dropdown/dropdown";
/**
* @param {any} value
*/
const pad2 = (value) => String(value).padStart(2, "0");
/**
* @template {any} [T=number]
* @param {number} length
* @param {(index: number) => T} mapping
*/
const range = (length, mapping = (n) => n) => [...Array(length)].map((_, i) => mapping(i));
const getTimeOptions = (rounding = 15) => {
const _hours = range(24, String);
const _minutes = range(60, (i) => i)
.filter((i) => i % rounding === 0)
.map((i) => pad2(i));
return _hours.flatMap((h) => _minutes.map((m) => `${h}:${m}`));
};
defineParams({
lang_parameters: {
time_format: "%H:%M:%S",
},
});
beforeEach(() => {
mockDate("2023-04-25T12:45:01");
});
test("default params, click on suggestion to select time", async () => {
await mountWithCleanup(TimePicker);
expect(".o_time_picker").toHaveCount(1);
expect("input.o_time_picker_input").toHaveValue("0:00");
await click(".o_time_picker_input");
await animationFrame();
expect(".o-dropdown--menu.o_time_picker_dropdown").toHaveCount(1);
expect(queryAllTexts(".o_time_picker_option")).toEqual(getTimeOptions());
await click(".o_time_picker_option:contains(12:15)");
await animationFrame();
expect("input.o_time_picker_input").toHaveValue("12:15");
});
test("when opening, select the suggestion equals to the props value", async () => {
await mountWithCleanup(TimePicker, {
props: {
value: "12:30",
},
});
expect("input.o_time_picker_input").toHaveValue("12:30");
await click(".o_time_picker_input");
await animationFrame();
expect(".o-dropdown--menu.o_time_picker_dropdown").toHaveCount(1);
expect(queryAllTexts(".o_time_picker_option")).toEqual(getTimeOptions());
expect(".o_time_picker_option:contains(12:30)").toHaveClass("focus");
});
test("onChange only triggers if the value has changed", async () => {
await mountWithCleanup(TimePicker, {
props: {
value: "12:15",
onChange: (value) => expect.step(`${value.hour}:${value.minute}`),
},
});
expect("input.o_time_picker_input").toHaveValue("12:15");
await click(".o_time_picker_input");
await animationFrame();
await click(".o_time_picker_option:contains(12:15)");
await animationFrame();
expect(".o-dropdown--menu.o_time_picker_dropdown").toHaveCount(0);
expect("input.o_time_picker_input").toHaveValue("12:15");
expect.verifySteps([]);
await click(".o_time_picker_input");
await animationFrame();
await click(".o_time_picker_option:contains(12:30)");
await animationFrame();
expect(".o-dropdown--menu.o_time_picker_dropdown").toHaveCount(0);
expect("input.o_time_picker_input").toHaveValue("12:30");
expect.verifySteps(["12:30"]);
});
test("seconds only shown and usable when 'showSeconds' is true", async () => {
await mountWithCleanup(TimePicker, {
props: {
showSeconds: true,
onChange: (value) => expect.step(`${value.hour}:${value.minute}:${value.second}`),
},
});
expect("input.o_time_picker_input").toHaveValue("0:00:00");
await click(".o_time_picker_input");
await animationFrame();
await click(".o_time_picker_option:contains(12:15)");
await animationFrame();
expect("input.o_time_picker_input").toHaveValue("12:15:00");
expect.verifySteps(["12:15:0"]);
await click(".o_time_picker_input");
await edit("15:25:33", { confirm: "enter" });
await animationFrame();
expect("input.o_time_picker_input").toHaveValue("15:25:33");
expect.verifySteps(["15:25:33"]);
});
test("handle 12h (am/pm) time format", async () => {
defineParams({
lang_parameters: {
time_format: "hh:mm:ss a",
},
});
await mountWithCleanup(TimePicker, {
props: {
onChange: (value) => expect.step(`${value.hour}:${value.minute}`),
},
});
expect("input.o_time_picker_input").toHaveValue("12:00am");
await click(".o_time_picker_input");
await animationFrame();
const M = range(60, (i) => i)
.filter((i) => i % 15 === 0)
.map((i) => pad2(i));
const H = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const options = [];
["am", "pm"].forEach((a) => H.forEach((h) => M.forEach((m) => options.push(`${h}:${m}${a}`))));
expect(queryAllTexts(".o_time_picker_dropdown .o_time_picker_option")).toEqual(options);
await edit("4:15pm", { confirm: "enter" });
await animationFrame();
expect("input.o_time_picker_input").toHaveValue("4:15pm");
// actual data is always in 24h format
expect.verifySteps(["16:15"]);
await edit("8:30", { confirm: "enter" });
await animationFrame();
// default to am when no meridiem is provided
expect("input.o_time_picker_input").toHaveValue("8:30am");
expect.verifySteps(["8:30"]);
});
test.tags("desktop");
test("validity updated on input and cannot apply non-valid time strings", async () => {
await mountWithCleanup(TimePicker, {
props: {
onChange: () => expect.step("change"),
},
});
await click(".o_time_picker_input");
await animationFrame();
await edit("gg ez", { confirm: false });
await animationFrame();
expect("input.o_time_picker_input").toHaveClass("o_invalid");
await press("enter");
await animationFrame();
expect.verifySteps([]);
await edit("12:30", { confirm: false });
await animationFrame();
expect("input.o_time_picker_input").not.toHaveClass("o_invalid");
expect.verifySteps([]);
await press("enter");
await animationFrame();
expect.verifySteps(["change"]);
});
test.tags("desktop");
test("arrow keys navigation, enter selects items, up/down arrow updates the input value", async () => {
await mountWithCleanup(TimePicker, {
props: {
onChange: (value) => expect.step(`${value.hour}:${value.minute}`),
},
});
await click(".o_time_picker_input");
await animationFrame();
expect("input.o_time_picker_input").toHaveValue("0:00");
await press("arrowdown");
await animationFrame();
expect("input.o_time_picker_input").toHaveValue("0:15");
await press("arrowup");
await press("arrowup");
await animationFrame();
expect("input.o_time_picker_input").toHaveValue("23:45");
await press("enter");
await animationFrame();
expect.verifySteps(["23:45"]);
});
test.tags("desktop");
test("if typing after navigating, enter validates input value", async () => {
await mountWithCleanup(TimePicker, {
props: {
onChange: (value) => expect.step(`${value.hour}:${value.minute}`),
},
});
await click(".o_time_picker_input");
await animationFrame();
await press("arrowdown");
await animationFrame();
expect("input.o_time_picker_input").toHaveValue("0:15");
await press("enter");
await animationFrame();
// Enter selects the navigated item
expect.verifySteps(["0:15"]);
await click(".o_time_picker_input");
await animationFrame();
await press("arrowdown");
await press("arrowdown");
await animationFrame();
expect("input.o_time_picker_input").toHaveValue("0:45");
await edit("12:5", { confirm: false });
await press("enter");
await animationFrame();
// Enter validates the edited input
expect.verifySteps(["12:50"]);
});
test("typing a value that is in the suggestions will focus it in the dropdown", async () => {
await mountWithCleanup(TimePicker);
await click(".o_time_picker_input");
await animationFrame();
await runAllTimers();
expect(".o_time_picker_option.focus").toHaveText("0:00");
await edit("12:3", { confirm: false });
await animationFrame();
expect(".o_time_picker_option.focus").toHaveText("12:30");
expect(".o_time_picker_option.focus").toBeVisible();
});
test("false, null and undefined are accepted values", async () => {
class Parent extends Component {
static components = { TimePicker };
static props = {};
static template = xml`<TimePicker value="state.value"/>`;
setup() {
this.state = useState({
value: null,
});
}
}
const comp = await mountWithCleanup(Parent);
expect(".o_time_picker_input").toHaveValue("");
comp.state.value = false;
await runAllTimers();
expect(".o_time_picker_input").toHaveValue("");
comp.state.value = undefined;
await runAllTimers();
expect(".o_time_picker_input").toHaveValue("0:00");
});
test("click-out triggers onChange", async () => {
class Parent extends Component {
static components = { TimePicker, Dropdown };
static props = {};
static template = xml`
<div>
<Dropdown>
<button class="open">Open</button>
<t t-set-slot="content">
<TimePicker onChange.bind="onChange"/>
</t>
</Dropdown>
<button class="outside">Outside</button>
</div>
`;
onChange(value) {
expect.step(`${value.hour}:${value.minute}`);
}
}
await mountWithCleanup(Parent);
await click(".open");
await animationFrame();
await click(".o_time_picker_input");
await animationFrame();
expect(".o_time_picker_option.focus").toHaveText("0:00");
await edit("12:3", { confirm: false });
await animationFrame();
expect.verifySteps([]);
await click(".outside");
await animationFrame();
expect(".o-dropdown--menu.o_time_picker_dropdown").toHaveCount(0);
expect.verifySteps(["12:30"]);
});
test("changing the props value updates the input", async () => {
class Parent extends Component {
static components = { TimePicker };
static props = {};
static template = xml`<TimePicker value="state.value" onChange.bind="onChange"/>`;
setup() {
this.state = useState({
value: null,
});
}
onChange(value) {
expect.step(`${value.hour}:${value.minute}`);
}
}
const comp = await mountWithCleanup(Parent);
expect(".o_time_picker_input").toHaveValue("");
// Set value from props
comp.state.value = "12:00";
await runAllTimers();
expect(".o_time_picker_input").toHaveValue("12:00");
expect.verifySteps([]);
// Set value by clicking
await click(".o_time_picker_input");
await animationFrame();
await click(`.o_time_picker_option:contains("11:30")`);
await animationFrame();
await runAllTimers();
expect.verifySteps(["11:30"]);
// Set falsy value from props
comp.state.value = false;
await runAllTimers();
expect(".o_time_picker_input").toHaveValue("");
expect.verifySteps([]);
});
test("ensure placeholder is customizable", async () => {
class Parent extends Component {
static components = { TimePicker };
static props = {};
static template = xml`<TimePicker placeholder="state.placeholder"/>`;
setup() {
this.state = useState({ placeholder: undefined });
}
}
const comp = await mountWithCleanup(Parent);
await animationFrame();
expect(".o_time_picker_input").toHaveAttribute("placeholder", "hh:mm");
comp.state.placeholder = "your time";
await animationFrame();
expect(".o_time_picker_input").toHaveAttribute("placeholder", "your time");
});
test("add a custom class", async () => {
class Parent extends Component {
static components = { TimePicker };
static props = {};
static template = xml`<TimePicker cssClass="'o_custom_class'"/>`;
}
await mountWithCleanup(Parent);
expect(".o_time_picker").toHaveClass("o_custom_class");
});
test("add a custom input class", async () => {
class Parent extends Component {
static components = { TimePicker };
static props = {};
static template = xml`<TimePicker inputCssClass="'o_custom_class'"/>`;
}
await mountWithCleanup(Parent);
expect(".o_time_picker_input").toHaveClass("o_custom_class");
});

View file

@ -0,0 +1,344 @@
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("hotkey 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 press("alt+q");
await tick();
expect.verifySteps(["Confirm action", "Close action"]);
});
test("hotkey 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 press("alt+x");
await tick();
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

@ -1,205 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { makeTestEnv } from "../helpers/mock_env";
import { click, getFixture, makeDeferred, mount, nextTick, triggerHotkey } from "../helpers/utils";
import { makeFakeDialogService } from "../helpers/mock_services";
const serviceRegistry = registry.category("services");
let target;
async function makeDialogTestEnv() {
const env = await makeTestEnv();
env.dialogData = {
isActive: true,
close: () => {},
};
return env;
}
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async (assert) => {
target = getFixture();
async function addDialog(dialogClass, props) {
assert.strictEqual(props.body, "Some content");
assert.strictEqual(props.title, "Confirmation");
}
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("ui", uiService);
serviceRegistry.add("dialog", makeFakeDialogService(addDialog), { force: true });
});
QUnit.module("ConfirmationDialog");
QUnit.test("pressing escape to close the dialog", async function (assert) {
const env = await makeDialogTestEnv();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
assert.step("Close action");
},
confirm: () => {},
cancel: () => {
assert.step("Cancel action");
},
},
});
assert.verifySteps([]);
triggerHotkey("escape");
await nextTick();
assert.verifySteps(
["Cancel action", "Close action"],
"dialog has called its cancel method before its closure"
);
});
QUnit.test("clicking on 'Ok'", async function (assert) {
const env = await makeDialogTestEnv();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
assert.step("Close action");
},
confirm: () => {
assert.step("Confirm action");
},
cancel: () => {
throw new Error("should not be called");
},
},
});
assert.verifySteps([]);
await click(target, ".modal-footer .btn-primary");
assert.verifySteps(["Confirm action", "Close action"]);
});
QUnit.test("clicking on 'Cancel'", async function (assert) {
const env = await makeDialogTestEnv();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
assert.step("Close action");
},
confirm: () => {
throw new Error("should not be called");
},
cancel: () => {
assert.step("Cancel action");
},
},
});
assert.verifySteps([]);
await click(target, ".modal-footer .btn-secondary");
assert.verifySteps(["Cancel action", "Close action"]);
});
QUnit.test("can't click twice on 'Ok'", async function (assert) {
const env = await makeDialogTestEnv();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {},
confirm: () => {
assert.step("Confirm");
},
cancel: () => {},
},
});
assert.notOk(target.querySelector(".modal-footer .btn-primary").disabled);
assert.notOk(target.querySelector(".modal-footer .btn-secondary").disabled);
click(target, ".modal-footer .btn-primary");
assert.ok(target.querySelector(".modal-footer .btn-primary").disabled);
assert.ok(target.querySelector(".modal-footer .btn-secondary").disabled);
assert.verifySteps(["Confirm"]);
});
QUnit.test("can't click twice on 'Cancel'", async function (assert) {
const env = await makeDialogTestEnv();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {},
confirm: () => {},
cancel: () => {
assert.step("Cancel");
},
},
});
assert.notOk(target.querySelector(".modal-footer .btn-primary").disabled);
assert.notOk(target.querySelector(".modal-footer .btn-secondary").disabled);
click(target, ".modal-footer .btn-secondary");
assert.ok(target.querySelector(".modal-footer .btn-primary").disabled);
assert.ok(target.querySelector(".modal-footer .btn-secondary").disabled);
assert.verifySteps(["Cancel"]);
});
QUnit.test("can't cancel (with escape) after confirm", async function (assert) {
const def = makeDeferred();
const env = await makeDialogTestEnv();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
assert.step("close");
},
confirm: () => {
assert.step("confirm");
return def;
},
cancel: () => {
throw new Error("should not cancel");
},
},
});
await click(target, ".modal-footer .btn-primary");
assert.verifySteps(["confirm"]);
triggerHotkey("escape");
await nextTick();
assert.verifySteps([]);
def.resolve();
await nextTick();
assert.verifySteps(["close"]);
});
QUnit.test("wait for confirm callback before closing", async function (assert) {
const env = await makeDialogTestEnv();
const def = makeDeferred();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
assert.step("close");
},
confirm: () => {
assert.step("confirm");
return def;
},
},
});
await click(target, ".modal-footer .btn-primary");
assert.verifySteps(["confirm"]);
def.resolve();
await nextTick();
assert.verifySteps(["close"]);
});
});

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

@ -1,45 +0,0 @@
/** @odoo-module **/
import { makeContext } from "@web/core/context";
QUnit.module("utils", {}, () => {
QUnit.module("makeContext");
QUnit.test("return empty context", (assert) => {
assert.deepEqual(makeContext([]), {});
});
QUnit.test("duplicate a context", (assert) => {
const ctx1 = { a: 1 };
const ctx2 = makeContext([ctx1]);
assert.notStrictEqual(ctx1, ctx2);
assert.deepEqual(ctx1, ctx2);
});
QUnit.test("can accept undefined or empty string", (assert) => {
assert.deepEqual(makeContext([undefined]), {});
assert.deepEqual(makeContext([{ a: 1 }, undefined, { b: 2 }]), { a: 1, b: 2 });
assert.deepEqual(makeContext([""]), {});
assert.deepEqual(makeContext([{ a: 1 }, "", { b: 2 }]), { a: 1, b: 2 });
});
QUnit.test("evaluate strings", (assert) => {
assert.deepEqual(makeContext(["{'a': 33}"]), { a: 33 });
});
QUnit.test("evaluated context is used as evaluation context along the way", (assert) => {
assert.deepEqual(makeContext([{ a: 1 }, "{'a': a + 1}"]), { a: 2 });
assert.deepEqual(makeContext([{ a: 1 }, "{'b': a + 1}"]), { a: 1, b: 2 });
assert.deepEqual(makeContext([{ a: 1 }, "{'b': a + 1}", "{'c': b + 1}"]), {
a: 1,
b: 2,
c: 3,
});
assert.deepEqual(makeContext([{ a: 1 }, "{'b': a + 1}", "{'a': b + 1}"]), { a: 3, b: 2 });
});
QUnit.test("initial evaluation context", (assert) => {
assert.deepEqual(makeContext(["{'a': a + 1}"], { a: 1 }), { a: 2 });
assert.deepEqual(makeContext(["{'b': a + 1}"], { a: 1 }), { b: 2 });
});
});

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

@ -1,806 +0,0 @@
/** @odoo-module **/
import { Component, useState, xml } from "@odoo/owl";
import { applyFilter, toggleMenu } from "@web/../tests/search/helpers";
import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { localization } from "@web/core/l10n/localization";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import ActionModel from "web.ActionModel";
import CustomFilterItem from "web.CustomFilterItem";
import { createComponent } from "web.test_utils";
import { editSelect } from "web.test_utils_fields";
import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import {
click,
editInput,
getFixture,
mount,
nextTick,
patchWithCleanup,
triggerEvent,
} from "../helpers/utils";
const { DateTime, Settings } = luxon;
const serviceRegistry = registry.category("services");
let target;
/**
* @param {typeof DatePicker} Picker
* @param {Object} props
* @returns {Promise<DatePicker>}
*/
async function mountPicker(Picker, props) {
serviceRegistry
.add(
"localization",
makeFakeLocalizationService({
dateFormat: "dd/MM/yyyy",
dateTimeFormat: "dd/MM/yyyy HH:mm:ss",
})
)
.add("ui", uiService)
.add("hotkey", hotkeyService);
class Parent extends Component {
setup() {
this.state = useState(props);
}
onDateChange(date) {
if (props.onDateTimeChanged) {
props.onDateTimeChanged(date);
}
this.state.date = date;
}
}
Parent.template = xml/* xml */ `
<t t-component="props.Picker" t-props="state" onDateTimeChanged.bind="onDateChange" />
`;
const env = await makeTestEnv();
return await mount(Parent, target, { env, props: { Picker } });
}
function useFRLocale() {
if (!window.moment.locales().includes("fr")) {
// Mocks the FR locale if not loaded
const originalLocale = window.moment.locale();
window.moment.defineLocale("fr", {
months: "janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split(
"_"
),
monthsShort: "janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split(
"_"
),
code: "fr",
monthsParseExact: true,
week: { dow: 1, doy: 4 },
});
// Moment automatically assigns newly defined locales.
window.moment.locale(originalLocale);
registerCleanup(() => window.moment.updateLocale("fr", null));
}
return "fr";
}
var symbolMap = {
1: "૧",
2: "૨",
3: "૩",
4: "૪",
5: "૫",
6: "૬",
7: "૭",
8: "૮",
9: "૯",
0: "",
};
var numberMap = {
"૧": "1",
"૨": "2",
"૩": "3",
"૪": "4",
"૫": "5",
"૬": "6",
"૭": "7",
"૮": "8",
"૯": "9",
"": "0",
};
function useGULocale() {
if (!window.moment.locales().includes("gu")) {
const originalLocale = window.moment.locale();
window.moment.defineLocale("gu", {
months: "જાન્યુઆરી_ફેબ્રુઆરી_માર્ચ_એપ્રિલ_મે_જૂન_જુલાઈ_ઑગસ્ટ_સપ્ટેમ્બર_ઑક્ટ્બર_નવેમ્બર_ડિસેમ્બર".split(
"_"
),
monthsShort: "જાન્યુ._ફેબ્રુ._માર્ચ_એપ્રિ._મે_જૂન_જુલા._ઑગ._સપ્ટે._ઑક્ટ્._નવે._ડિસે.".split(
"_"
),
monthsParseExact: true,
week: {
dow: 0, // Sunday is the first day of the week.
doy: 6, // The week that contains Jan 1st is the first week of the year.
},
preparse: function (string) {
return string.replace(/[૧૨૩૪૫૬૭૮૯૦]/g, function (match) {
return numberMap[match];
});
},
postformat: function (string) {
return string.replace(/\d/g, function (match) {
return symbolMap[match];
});
},
});
// Moment automatically assigns newly defined locales.
window.moment.locale(originalLocale);
registerCleanup(() => window.moment.updateLocale("gu", null));
}
return "gu";
}
function useNOLocale() {
if (!window.moment.locales().includes("nb")) {
const originalLocale = window.moment.locale();
window.moment.defineLocale("nb", {
months: "januar_februar_mars_april_mai_juni_juli_august_september_oktober_november_desember".split(
"_"
),
monthsShort: "jan._feb._mars_april_mai_juni_juli_aug._sep._okt._nov._des.".split("_"),
monthsParseExact: true,
week: {
dow: 1, // Monday is the first day of the week.
doy: 4, // The week that contains Jan 4th is the first week of the year.
},
});
// Moment automatically assigns newly defined locales.
window.moment.locale(originalLocale);
registerCleanup(() => window.moment.updateLocale("nb", null));
}
return "nb";
}
QUnit.module("Components", ({ beforeEach }) => {
beforeEach(() => {
target = getFixture();
});
QUnit.module("DatePicker");
QUnit.test("basic rendering", async function (assert) {
assert.expect(8);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
});
assert.containsOnce(target, "input.o_input.o_datepicker_input");
assert.containsOnce(target, "span.o_datepicker_button");
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget");
const datePicker = target.querySelector(".o_datepicker");
const input = datePicker.querySelector("input.o_input.o_datepicker_input");
assert.strictEqual(input.value, "09/01/1997", "Value should be the one given");
assert.strictEqual(
datePicker.dataset.targetInput,
`#${datePicker.querySelector("input[type=hidden]").id}`,
"DatePicker id should match its input target"
);
await click(input);
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker");
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget .timepicker");
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"01/09/1997",
"Datepicker should have set the correct day"
);
});
QUnit.test("pick a date", async function (assert) {
assert.expect(5);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"08/02/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
await click(input);
await click(document.querySelector(".datepicker th.next")); // next month
assert.verifySteps([]);
await click(document.querySelectorAll(".datepicker table td")[15]); // previous day
assert.strictEqual(input.value, "08/02/1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date with locale (locale given in props)", async function (assert) {
assert.expect(5);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
format: "dd MMM, yyyy",
locale: useFRLocale(),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"01/09/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "09 janv., 1997");
await click(input);
await click(document.querySelector(".datepicker .picker-switch")); // month picker
await click(document.querySelectorAll(".datepicker .month")[8]); // september
await click(document.querySelector(".datepicker .day")); // first day
assert.strictEqual(input.value, "01 sept., 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date with locale (locale from date props)", async function (assert) {
assert.expect(5);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", {
zone: "utc",
locale: useFRLocale(),
}),
format: "dd MMM, yyyy",
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"01/09/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "09 janv., 1997");
await click(input);
await click(document.querySelector(".datepicker .picker-switch")); // month picker
await click(document.querySelectorAll(".datepicker .month")[8]); // september
await click(document.querySelector(".datepicker .day")); // first day
assert.strictEqual(input.value, "01 sept., 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date with locale (locale with different symbols)", async function (assert) {
assert.expect(6);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", {
zone: "utc",
locale: useGULocale(),
}),
format: "dd MMM, yyyy",
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"01/09/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "09 જાન્યુ, 1997");
await click(input);
assert.strictEqual(input.value, "09 જાન્યુ, 1997");
await click(document.querySelector(".datepicker .picker-switch")); // month picker
await click(document.querySelectorAll(".datepicker .month")[8]); // september
await click(document.querySelectorAll(".datepicker .day")[1]); // first day of september
assert.strictEqual(input.value, "01 સપ્ટે, 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("enter a date value", async function (assert) {
assert.expect(5);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"08/02/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.verifySteps([]);
input.value = "08/02/1997";
await triggerEvent(target, ".o_datepicker_input", "change");
assert.verifySteps(["datetime-changed"]);
await click(input);
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"02/08/1997",
"Datepicker should have set the correct day"
);
});
QUnit.test("Date format is correctly set", async function (assert) {
assert.expect(2);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
format: "yyyy/MM/dd",
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "1997/01/09");
// Forces an update to assert that the registered format is the correct one
await click(input);
assert.strictEqual(input.value, "1997/01/09");
});
QUnit.test("Validate input date with 'Enter'", async (assert) => {
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
format: "dd/MM/yyyy",
});
const input = target.querySelector(".o_datepicker_input");
await click(input);
assert.strictEqual(input.value, "09/01/1997");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
input.value = "23/03/2022";
await triggerEvent(input, null, "keydown", { key: "Enter" });
assert.strictEqual(input.value, "23/03/2022");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
});
QUnit.test("Validate input date with 'Escape'", async (assert) => {
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
format: "dd/MM/yyyy",
});
const input = target.querySelector(".o_datepicker_input");
await click(input);
assert.strictEqual(input.value, "09/01/1997");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
input.value = "23/03/2022";
await triggerEvent(input, null, "keydown", { key: "Escape" });
assert.strictEqual(input.value, "23/03/2022");
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
});
QUnit.module("DateTimePicker");
QUnit.test("basic rendering", async function (assert) {
assert.expect(11);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
});
assert.containsOnce(target, "input.o_input.o_datepicker_input");
assert.containsOnce(target, "span.o_datepicker_button");
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget");
const datePicker = target.querySelector(".o_datepicker");
const input = datePicker.querySelector("input.o_input.o_datepicker_input");
assert.strictEqual(input.value, "09/01/1997 12:30:01", "Value should be the one given");
assert.strictEqual(
datePicker.dataset.targetInput,
`#${datePicker.querySelector("input[type=hidden]").id}`,
"DateTimePicker id should match its input target"
);
await click(input);
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker");
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .timepicker");
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"01/09/1997",
"Datepicker should have set the correct day"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-hour").innerText.trim(),
"12",
"Datepicker should have set the correct hour"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-minute").innerText.trim(),
"30",
"Datepicker should have set the correct minute"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-second").innerText.trim(),
"01",
"Datepicker should have set the correct second"
);
});
QUnit.test("pick a date and time", async function (assert) {
assert.expect(5);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy HH:mm:ss"),
"08/02/1997 15:45:05",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector("input.o_input.o_datepicker_input");
await click(input);
await click(document.querySelector(".datepicker th.next")); // February
await click(document.querySelectorAll(".datepicker table td")[15]); // 08
await click(document.querySelector('a[title="Select Time"]'));
await click(document.querySelector(".timepicker .timepicker-hour"));
await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h
await click(document.querySelector(".timepicker .timepicker-minute"));
await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m
await click(document.querySelector(".timepicker .timepicker-second"));
assert.verifySteps([]);
await click(document.querySelectorAll(".timepicker .second")[1]); // 05s
assert.strictEqual(input.value, "08/02/1997 15:45:05");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date and time with locale", async function (assert) {
assert.expect(6);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
format: "dd MMM, yyyy HH:mm:ss",
locale: useFRLocale(),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy HH:mm:ss"),
"01/09/1997 15:45:05",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector("input.o_input.o_datepicker_input");
assert.strictEqual(input.value, "09 janv., 1997 12:30:01");
await click(input);
await click(document.querySelector(".datepicker .picker-switch")); // month picker
await click(document.querySelectorAll(".datepicker .month")[8]); // september
await click(document.querySelector(".datepicker .day")); // first day
await click(document.querySelector('a[title="Select Time"]'));
await click(document.querySelector(".timepicker .timepicker-hour"));
await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h
await click(document.querySelector(".timepicker .timepicker-minute"));
await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m
await click(document.querySelector(".timepicker .timepicker-second"));
assert.verifySteps([]);
await click(document.querySelectorAll(".timepicker .second")[1]); // 05s
assert.strictEqual(input.value, "01 sept., 1997 15:45:05");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a time with 12 hour format locale", async function (assert) {
assert.expect(6);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 08:30:01", "dd/MM/yyyy hh:mm:ss"),
format: "dd/MM/yyyy hh:mm:ss",
locale: useFRLocale(),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy HH:mm:ss"),
"09/01/1997 20:30:02",
"The new time should be in the afternoon"
);
},
});
const input = target.querySelector("input.o_input.o_datepicker_input");
assert.strictEqual(input.value, "09/01/1997 08:30:01");
await click(input);
await click(document.querySelector('a[title="Select Time"]'));
await click(document.querySelector('a[title="Increment Second"]'));
await click(document.querySelector('button[title="Toggle Period"]'));
assert.verifySteps([]);
await click(document.querySelector('a[title="Close the picker"]'));
assert.strictEqual(input.value, "09/01/1997 08:30:02");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("enter a datetime value", async function (assert) {
assert.expect(9);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy HH:mm:ss"),
"08/02/1997 15:45:05",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.verifySteps([]);
input.value = "08/02/1997 15:45:05";
await triggerEvent(target, ".o_datepicker_input", "change");
assert.verifySteps(["datetime-changed"]);
await click(input);
assert.strictEqual(input.value, "08/02/1997 15:45:05");
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"02/08/1997",
"Datepicker should have set the correct day"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-hour").innerText.trim(),
"15",
"Datepicker should have set the correct hour"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-minute").innerText.trim(),
"45",
"Datepicker should have set the correct minute"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-second").innerText.trim(),
"05",
"Datepicker should have set the correct second"
);
});
QUnit.test("Date time format is correctly set", async function (assert) {
assert.expect(2);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
format: "HH:mm:ss yyyy/MM/dd",
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "12:30:01 1997/01/09");
// Forces an update to assert that the registered format is the correct one
await click(input);
assert.strictEqual(input.value, "12:30:01 1997/01/09");
});
QUnit.test("Datepicker works with norwegian locale", async (assert) => {
assert.expect(6);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/04/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
format: "dd MMM, yyyy",
locale: useNOLocale(),
onDateTimeChanged(date) {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"01/04/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "09 apr., 1997");
await click(input);
assert.strictEqual(input.value, "09 apr., 1997");
const days = [...document.querySelectorAll(".datepicker .day")];
await click(days.find((d) => d.innerText.trim() === "1")); // first day of april
assert.strictEqual(input.value, "01 apr., 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("Datepicker works with dots and commas in format", async (assert) => {
assert.expect(2);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("10/03/2023 13:14:27", "dd/MM/yyyy HH:mm:ss"),
format: "dd.MM,yyyy",
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "10.03,2023");
await click(input);
assert.strictEqual(input.value, "10.03,2023");
});
QUnit.test("custom filter date", async function (assert) {
assert.expect(3);
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, "createNewFilters");
const preFilters = args[0];
const preFilter = preFilters[0];
assert.strictEqual(
preFilter.description,
'A date is equal to "05/05/2005"',
"description should be in localized format"
);
assert.deepEqual(
preFilter.domain,
'[["date_field","=","2005-05-05"]]',
"domain should be in UTC format"
);
}
}
const searchModel = new MockedSearchModel();
const date_field = { name: "date_field", string: "A date", type: "date", searchable: true };
await createComponent(CustomFilterItem, {
props: {
fields: { date_field },
},
env: { searchModel },
});
await toggleMenu(target, "Add Custom Filter");
await editSelect(target.querySelector(".o_generator_menu_field"), "date_field");
const valueInput = target.querySelector(".o_generator_menu_value .o_input");
await click(valueInput);
await editSelect(valueInput, "05/05/2005");
await applyFilter(target);
});
QUnit.test("start with no value", async function (assert) {
assert.expect(6);
await mountPicker(DateTimePicker, {
onDateTimeChanged(date) {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy HH:mm:ss"),
"08/02/1997 15:45:05",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "");
assert.verifySteps([]);
input.value = "08/02/1997 15:45:05";
await triggerEvent(target, ".o_datepicker_input", "change");
assert.verifySteps(["datetime-changed"]);
assert.strictEqual(input.value, "08/02/1997 15:45:05");
});
QUnit.test("arab locale, latin numbering system as input", async (assert) => {
const dateFormat = "dd MMM, yyyy";
const timeFormat = "hh:mm:ss";
const dateTimeFormat = `${dateFormat} ${timeFormat}`;
patchWithCleanup(localization, { dateFormat, timeFormat, dateTimeFormat });
patchWithCleanup(Settings, {
defaultLocale: "ar-001",
defaultNumberingSystem: "arab",
});
await mountPicker(DateTimePicker, {
format: dateTimeFormat,
});
const input = target.querySelector(".o_datepicker_input");
await editInput(input, null, "٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
assert.strictEqual(input.value, "٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
await editInput(input, null, "15 07, 2020 12:30:43");
assert.strictEqual(input.value, "١٥ يوليو, ٢٠٢٠ ١٢:٣٠:٤٣");
});
QUnit.test("keep date between component and datepicker in sync", async (assert) => {
const parent = await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
format: "dd/MM/yyyy",
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "09/01/1997");
await nextTick();
await click(input);
assert.hasClass(document.querySelector("td.day[data-day='01/09/1997']"), "active");
// Change the date of the component externally (not through the
// datepicker interface)
parent.state.date = parent.state.date.plus({ days: 1 });
await nextTick();
assert.strictEqual(input.value, "10/01/1997");
assert.hasClass(document.querySelector("td.day[data-day='01/10/1997']"), "active");
parent.state.date = false;
await nextTick();
assert.strictEqual(input.value, "");
assert.containsN(document.body, "td.day", 42);
assert.containsNone(document.body, "td.day.active");
});
});

View file

@ -0,0 +1,151 @@
import { expect } from "@odoo/hoot";
import { click, edit, queryAll, queryAllTexts, 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];
expect(
`.o_time_picker:nth-child(${i + 1} of .o_time_picker) .o_time_picker_input`
).toHaveValue(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 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) {
// 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_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 async function zoomOut() {
click(".o_zoom_out");
await animationFrame();
}
export async function editTime(time, timepickerIndex = 0) {
await click(`.o_time_picker_input:eq(${timepickerIndex})`);
await animationFrame();
await edit(time, { confirm: "enter" });
await animationFrame();
}

View file

@ -0,0 +1,768 @@
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("has_access", () => true);
onRpc("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", () => ({
type: "item",
description: "Item 1",
callback: () => {
expect.step("callback item_1");
},
sequence: 10,
section: "a",
}))
.add("item_2", () => ({
type: "item",
description: "Item 2",
callback: () => {
expect.step("callback item_2");
},
sequence: 5,
section: "a",
}))
.add("item_3", () => ({
type: "item",
description: "Item 3",
callback: () => {
expect.step("callback item_3");
},
section: "b",
}))
.add("item_4", () => 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", () => ({
type: "item",
description: "Item 4",
sequence: 4,
}))
.add("item_2", () => ({
type: "item",
description: "Item 1",
sequence: 1,
}));
debugRegistry
.category("custom")
.add("item_1", () => ({
type: "item",
description: "Item 3",
sequence: 3,
}))
.add("item_2", () => ({
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", () => ({
type: "item",
description: "Global 1",
callback: () => {
expect.step("callback global_1");
},
sequence: 0,
}));
debugRegistry
.category("custom")
.add("item1", () => ({
type: "item",
description: "Item 1",
callback: () => {
expect.step("callback item_1");
},
sequence: 10,
}))
.add("item2", ({ customKey }) => ({
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", () => {
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", ({ args }) => {
expect(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();
parent_id = fields.Many2one({ relation: "custom" });
properties = fields.Properties({
string: "Properties",
definition_record: "parent_id",
definition_record_field: "definitions",
});
definitions = fields.PropertiesDefinition({
string: "Definitions",
});
_records = [
{
id: 1,
name: "custom1",
raw: "<raw>",
definitions: [{ name: "xphone_prop_1", string: "P1", type: "boolean" }],
},
];
}
const fnames = Object.keys(Custom._fields).map((fname) => `<field name="${fname}"/>`);
Custom._views.form = `<form>\n${fnames.join("\n")}\n</form>`;
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", () => [
{
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";
onRpc("ir.default", "set", ({ args }) => {
expect.step(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.verifySteps([
["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);
onRpc("ir.default", "set", ({ args }) => {
expect.step(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.verifySteps([["partner", "foo", fooValue, true, true, false]]);
});
});

View file

@ -1,884 +0,0 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { DebugMenu } from "@web/core/debug/debug_menu";
import { regenerateAssets, becomeSuperuser } from "@web/core/debug/debug_menu_items";
import { registry } from "@web/core/registry";
import { useDebugCategory, useOwnDebugContext } from "@web/core/debug/debug_context";
import { ormService } from "@web/core/orm_service";
import { uiService } from "@web/core/ui/ui_service";
import { useSetupView } from "@web/views/view_hook";
import { ActionDialog } from "@web/webclient/actions/action_dialog";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { makeTestEnv, utils } from "../../helpers/mock_env";
import {
fakeCompanyService,
fakeCommandService,
makeFakeDialogService,
makeFakeLocalizationService,
makeFakeUserService,
} from "../../helpers/mock_services";
import {
click,
getFixture,
getNodesTextContent,
legacyExtraNextTick,
mount,
nextTick,
patchWithCleanup,
} from "../../helpers/utils";
import { createWebClient, doAction, getActionManagerServerData } from "../../webclient/helpers";
import { openViewItem } from "@web/webclient/debug_items";
import { editSearchView, editView, setDefaults, viewMetadata } from "@web/views/debug_items";
import { Component, xml } from "@odoo/owl";
const { prepareRegistriesWithCleanup } = utils;
export class DebugMenuParent extends Component {
setup() {
useOwnDebugContext({ categories: ["default", "custom"] });
}
}
DebugMenuParent.template = xml`<DebugMenu/>`;
DebugMenuParent.components = { DebugMenu };
const debugRegistry = registry.category("debug");
let target;
let testConfig;
QUnit.module("DebugMenu", (hooks) => {
hooks.beforeEach(async () => {
target = getFixture();
registry
.category("services")
.add("hotkey", hotkeyService)
.add("ui", uiService)
.add("orm", ormService)
.add("dialog", makeFakeDialogService())
.add("localization", makeFakeLocalizationService())
.add("command", fakeCommandService);
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
};
testConfig = { mockRPC };
});
QUnit.test("can be rendered", async (assert) => {
debugRegistry
.category("default")
.add("item_1", () => {
return {
type: "item",
description: "Item 1",
callback: () => {
assert.step("callback item_1");
},
sequence: 10,
};
})
.add("item_2", () => {
return {
type: "item",
description: "Item 2",
callback: () => {
assert.step("callback item_2");
},
sequence: 5,
};
})
.add("item_3", () => {
return {
type: "item",
description: "Item 3",
callback: () => {
assert.step("callback item_3");
},
};
})
.add("separator", () => {
return {
type: "separator",
sequence: 20,
};
})
.add("separator_2", () => {
return null;
})
.add("item_4", () => {
return null;
});
const env = await makeTestEnv(testConfig);
await mount(DebugMenuParent, target, { env });
await click(target.querySelector("button.dropdown-toggle"));
assert.containsN(target, ".dropdown-menu .dropdown-item", 3);
assert.containsOnce(target, ".dropdown-divider");
const children = [...(target.querySelector(".dropdown-menu").children || [])];
assert.deepEqual(
children.map((el) => el.tagName),
["SPAN", "SPAN", "DIV", "SPAN"]
);
const items = [...target.querySelectorAll(".dropdown-menu .dropdown-item")] || [];
assert.deepEqual(
items.map((el) => el.textContent),
["Item 2", "Item 1", "Item 3"]
);
for (const item of items) {
click(item);
}
assert.verifySteps(["callback item_2", "callback item_1", "callback item_3"]);
});
QUnit.test("items are sorted by sequence regardless of category", async (assert) => {
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,
};
});
const env = await makeTestEnv(testConfig);
await mount(DebugMenuParent, target, { env });
await click(target.querySelector("button.dropdown-toggle"));
const items = [...target.querySelectorAll(".dropdown-menu .dropdown-item")];
assert.deepEqual(
items.map((el) => el.textContent),
["Item 1", "Item 2", "Item 3", "Item 4"]
);
});
QUnit.test("Don't display the DebugMenu if debug mode is disabled", async (assert) => {
const env = await makeTestEnv(testConfig);
env.dialogData = {
isActive: true,
close() {},
};
await mount(ActionDialog, target, {
env,
props: { close: () => {} },
});
assert.containsOnce(target, ".o_dialog");
assert.containsNone(target, ".o_dialog .o_debug_manager .fa-bug");
});
QUnit.test(
"Display the DebugMenu correctly in a ActionDialog if debug mode is enabled",
async (assert) => {
assert.expect(8);
debugRegistry.category("default").add("global", () => {
return {
type: "item",
description: "Global 1",
callback: () => {
assert.step("callback global_1");
},
sequence: 0,
};
});
debugRegistry
.category("custom")
.add("item1", () => {
return {
type: "item",
description: "Item 1",
callback: () => {
assert.step("callback item_1");
},
sequence: 10,
};
})
.add("item2", ({ customKey }) => {
return {
type: "item",
description: "Item 2",
callback: () => {
assert.step("callback item_2");
assert.strictEqual(customKey, "abc");
},
sequence: 20,
};
});
class WithCustom extends ActionDialog {
setup() {
super.setup(...arguments);
useDebugCategory("custom", { customKey: "abc" });
}
}
patchWithCleanup(odoo, { debug: "1" });
const env = await makeTestEnv(testConfig);
env.dialogData = {
isActive: true,
close() {},
};
await mount(WithCustom, target, {
env,
props: { close: () => {} },
});
assert.containsOnce(target, ".o_dialog");
assert.containsOnce(target, ".o_dialog .o_debug_manager .fa-bug");
await click(target, ".o_dialog .o_debug_manager button");
const debugManagerEl = target.querySelector(".o_debug_manager");
assert.containsN(debugManagerEl, ".dropdown-menu .dropdown-item", 2);
// Check that global debugManager elements are not displayed (global_1)
const items =
[...debugManagerEl.querySelectorAll(".dropdown-menu .dropdown-item")] || [];
assert.deepEqual(
items.map((el) => el.textContent),
["Item 1", "Item 2"]
);
for (const item of items) {
click(item);
}
assert.verifySteps(["callback item_1", "callback item_2"]);
}
);
QUnit.test("can regenerate assets bundles", async (assert) => {
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
if (route === "/web/dataset/call_kw/ir.attachment/regenerate_assets_bundles") {
assert.step("ir.attachment/regenerate_assets_bundles");
return Promise.resolve(true);
}
};
testConfig = { mockRPC };
patchWithCleanup(browser, {
location: {
reload: () => assert.step("reloadPage"),
},
});
debugRegistry.category("default").add("regenerateAssets", regenerateAssets);
const env = await makeTestEnv(testConfig);
await mount(DebugMenuParent, target, { env });
await click(target.querySelector("button.dropdown-toggle"));
assert.containsOnce(target, ".dropdown-menu .dropdown-item");
const item = target.querySelector(".dropdown-menu .dropdown-item");
assert.strictEqual(item.textContent, "Regenerate Assets Bundles");
await click(item);
assert.verifySteps(["ir.attachment/regenerate_assets_bundles", "reloadPage"]);
});
QUnit.test("cannot acess the Become superuser menu if not admin", async (assert) => {
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
};
debugRegistry.category("default").add("becomeSuperuser", becomeSuperuser);
testConfig = { mockRPC };
const env = await makeTestEnv(testConfig);
env.services.user.isAdmin = false;
await mount(DebugMenuParent, target, { env });
await click(target.querySelector("button.dropdown-toggle"));
assert.containsNone(target, ".dropdown-menu .dropdown-item");
});
QUnit.test("can open a view", async (assert) => {
assert.expect(3);
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
};
prepareRegistriesWithCleanup();
registry.category("services").add("company", fakeCompanyService);
patchWithCleanup(odoo, {
debug: true,
});
registry.category("debug").category("default").add("openViewItem", openViewItem);
const serverData = getActionManagerServerData();
Object.assign(serverData.models, {
"ir.ui.view": {
fields: {
model: { type: "char" },
name: { type: "char" },
type: { type: "char" },
},
records: [
{
id: 1,
name: "formView",
model: "partner",
type: "form",
},
],
},
});
Object.assign(serverData.views, {
"ir.ui.view,false,list": `<list><field name="name"/><field name="type"/></list>`,
"ir.ui.view,false,search": `<search/>`,
"partner,1,form": `<form><div class="some_view"/></form>`,
});
await createWebClient({ serverData, mockRPC });
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
assert.containsOnce(target, ".modal .o_list_view");
await click(target.querySelector(".modal .o_list_view .o_data_row td"));
assert.containsNone(target, ".modal");
assert.containsOnce(target, ".some_view");
});
QUnit.test("can edit a pivot view", async (assert) => {
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
};
prepareRegistriesWithCleanup();
patchWithCleanup(odoo, {
debug: true,
});
registry.category("services").add("user", makeFakeUserService());
registry.category("debug").category("view").add("editViewItem", editView);
const serverData = getActionManagerServerData();
serverData.actions[1234] = {
id: 1234,
xml_id: "action_1234",
name: "Reporting Ponies",
res_model: "pony",
type: "ir.actions.act_window",
views: [[18, "pivot"]],
};
serverData.views["pony,18,pivot"] = "<pivot></pivot>";
serverData.models["ir.ui.view"] = {
fields: {},
records: [{ id: 18 }],
};
serverData.views["ir.ui.view,false,form"] = `<form><field name="id"/></form>`;
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1234);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
assert.containsOnce(target, ".modal .o_form_view");
assert.strictEqual(
target.querySelector(".modal .o_form_view .o_field_widget[name=id] input").value,
"18"
);
});
QUnit.test("can edit a search view", async (assert) => {
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
};
prepareRegistriesWithCleanup();
registry.category("services").add("company", fakeCompanyService);
patchWithCleanup(odoo, {
debug: true,
});
registry.category("debug").category("view").add("editSearchViewItem", editSearchView);
const serverData = getActionManagerServerData();
serverData.views["partner,293,search"] = "<search></search>";
serverData.actions[1].search_view_id = [293, "some_search_view"];
serverData.models["ir.ui.view"] = {
fields: {},
records: [{ id: 293 }],
};
serverData.views["ir.ui.view,false,form"] = `<form><field name="id"/></form>`;
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
await legacyExtraNextTick();
assert.containsOnce(target, ".modal .o_form_view");
assert.strictEqual(
target.querySelector(".modal .o_form_view .o_field_widget[name=id] input").value,
"293"
);
});
QUnit.test("edit search view on action without search_view_id", async (assert) => {
// When the kanban view will be converted to Owl, this test could be simplified by
// removing the toy view and using the kanban view directly
prepareRegistriesWithCleanup();
class ToyController extends Component {
setup() {
useSetupView();
}
}
ToyController.template = xml`<div class="o-toy-view"/>`;
registry.category("views").add("toy", {
type: "toy",
display_name: "toy view",
Controller: ToyController,
});
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
};
patchWithCleanup(odoo, {
debug: true,
});
registry.category("debug").category("view").add("editSearchViewItem", editSearchView);
const serverData = getActionManagerServerData();
serverData.actions[1] = {
id: 1,
xml_id: "action_1",
name: "Partners Action 1",
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "toy"]],
search_view_id: false,
};
serverData.models["ir.ui.view"] = {
fields: {},
records: [{ id: 293 }],
};
serverData.views = {};
serverData.views["ir.ui.view,false,form"] = `<form><field name="id"/></form>`;
serverData.views["partner,false,toy"] = `<toy></toy>`;
serverData.views["partner,293,search"] = `<search></search>`;
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1);
assert.containsOnce(target, ".o-toy-view");
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
await legacyExtraNextTick();
assert.containsOnce(target, ".modal .o_form_view");
assert.strictEqual(
target.querySelector(".modal .o_form_view .o_field_widget[name=id] input").value,
"293"
);
});
QUnit.test(
"cannot edit the control panel of a form view contained in a dialog without control panel.",
async (assert) => {
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
};
prepareRegistriesWithCleanup();
patchWithCleanup(odoo, {
debug: true,
});
registry.category("debug").category("view").add("editSearchViewItem", editSearchView);
const serverData = getActionManagerServerData();
const webClient = await createWebClient({ serverData, mockRPC });
// opens a form view in a dialog without a control panel.
await doAction(webClient, 5);
await click(target.querySelector(".o_dialog .o_debug_manager button"));
assert.containsNone(target, ".o_debug_manager .dropdown-item");
}
);
QUnit.test("set defaults: basic rendering", async (assert) => {
prepareRegistriesWithCleanup();
patchWithCleanup(odoo, {
debug: true,
});
registry.category("services").add("user", makeFakeUserService());
registry.category("debug").category("form").add("setDefaults", setDefaults);
const serverData = getActionManagerServerData();
serverData.actions[1234] = {
id: 1234,
xml_id: "action_1234",
name: "Partners",
res_model: "partner",
res_id: 1,
type: "ir.actions.act_window",
views: [[18, "form"]],
};
serverData.views["partner,18,form"] = `
<form>
<field name="m2o"/>
<field name="foo"/>
<field name="o2m"/>
</form>`;
serverData.models["ir.ui.view"] = {
fields: {},
records: [{ id: 18 }],
};
serverData.models.partner.records = [{ id: 1, display_name: "p1", foo: "hello" }];
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1234);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal select#formview_default_fields");
assert.containsN(target.querySelector(".modal #formview_default_fields"), "option", 2);
const options = target.querySelectorAll(".modal #formview_default_fields option");
assert.strictEqual(options[0].value, "");
assert.strictEqual(options[1].value, "foo");
});
QUnit.test("set defaults: click close", async (assert) => {
prepareRegistriesWithCleanup();
patchWithCleanup(odoo, {
debug: true,
});
registry.category("services").add("user", makeFakeUserService());
registry.category("debug").category("form").add("setDefaults", setDefaults);
const serverData = getActionManagerServerData();
serverData.actions[1234] = {
id: 1234,
xml_id: "action_1234",
name: "Partners",
res_model: "partner",
res_id: 1,
type: "ir.actions.act_window",
views: [[18, "form"]],
};
serverData.views["partner,18,form"] = `
<form>
<field name="foo"/>
</form>`;
serverData.models["ir.ui.view"] = {
fields: {},
records: [{ id: 18 }],
};
serverData.models.partner.records = [{ id: 1, display_name: "p1", foo: "hello" }];
const mockRPC = async (route, args) => {
if (args.method === "set" && args.model === "ir.default") {
throw new Error("should not create a default");
}
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1234);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
assert.containsOnce(target, ".modal");
await click(target.querySelector(".modal .modal-footer button"));
assert.containsNone(target, ".modal");
});
QUnit.test("set defaults: select and save", async (assert) => {
assert.expect(3);
prepareRegistriesWithCleanup();
patchWithCleanup(odoo, {
debug: true,
});
registry.category("services").add("user", makeFakeUserService());
registry.category("debug").category("form").add("setDefaults", setDefaults);
const serverData = getActionManagerServerData();
serverData.actions[1234] = {
id: 1234,
xml_id: "action_1234",
name: "Partners",
res_model: "partner",
res_id: 1,
type: "ir.actions.act_window",
views: [[18, "form"]],
};
serverData.views["partner,18,form"] = `
<form>
<field name="foo"/>
</form>`;
serverData.models["ir.ui.view"] = {
fields: {},
records: [{ id: 18 }],
};
serverData.models.partner.records = [{ id: 1, display_name: "p1", foo: "hello" }];
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
if (args.method === "set" && args.model === "ir.default") {
assert.deepEqual(args.args, ["partner", "foo", "hello", true, true, false]);
return true;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1234);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
assert.containsOnce(target, ".modal");
const select = target.querySelector(".modal #formview_default_fields");
select.value = "foo";
select.dispatchEvent(new Event("change"));
await nextTick();
await click(target.querySelectorAll(".modal .modal-footer button")[1]);
assert.containsNone(target, ".modal");
});
QUnit.test("view metadata: basic rendering", async (assert) => {
prepareRegistriesWithCleanup();
patchWithCleanup(odoo, {
debug: true,
});
registry.category("services").add("user", makeFakeUserService());
registry.category("debug").category("form").add("viewMetadata", viewMetadata);
const serverData = getActionManagerServerData();
serverData.actions[1234] = {
id: 1234,
xml_id: "action_1234",
name: "Partners",
res_model: "partner",
res_id: 27,
type: "ir.actions.act_window",
views: [[false, "form"]],
};
serverData.models.partner.records = [{ id: 27, display_name: "p1" }];
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
if (args.method === "get_metadata") {
return [
{
create_date: "2023-01-26 14:12:10",
create_uid: [4, "Some user"],
id: 27,
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 }],
},
];
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1234);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
assert.containsOnce(target, ".modal");
assert.deepEqual(
getNodesTextContent(
target.querySelectorAll(".modal-body table tr th, .modal-body table tr td")
),
[
"ID:",
"27",
"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",
]
);
});
QUnit.test("set defaults: setting default value for datetime field", async (assert) => {
assert.expect(7);
prepareRegistriesWithCleanup();
patchWithCleanup(odoo, {
debug: true,
});
registry.category("services").add("user", makeFakeUserService());
registry.category("debug").category("form").add("setDefaults", setDefaults);
const serverData = getActionManagerServerData();
serverData.actions[1234] = {
id: 1234,
xml_id: "action_1234",
name: "Partners",
res_model: "partner",
res_id: 1,
type: "ir.actions.act_window",
views: [[18, "form"]],
};
serverData.models.partner.fields.datetime = {string: 'Datetime', type: 'datetime'}
serverData.models.partner.fields.reference = {string: 'Reference', type: 'reference', selection: [["pony", "Pony"]]}
serverData.views["partner,18,form"] = `
<form>
<field name="datetime"/>
<field name="reference"/>
<field name="m2o"/>
</form>`;
serverData.models["ir.ui.view"] = {
fields: {},
records: [{ id: 18 }],
};
serverData.models.pony.records = [{
id: 1,
name: "Test"
}];
serverData.models.partner.records = [{
id: 1,
display_name: "p1",
datetime: "2024-01-24 16:46:16",
reference: 'pony,1',
m2o: 1
}];
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
if (args.method === "set" && args.model === "ir.default") {
arg_steps.push(args.args)
return true;
}
};
const webClient = await createWebClient({serverData, mockRPC});
let arg_steps = [];
for (const field_name of ['datetime', 'reference', 'm2o']) {
await doAction(webClient, 1234);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
assert.containsOnce(target, ".modal");
const select = target.querySelector(".modal #formview_default_fields");
select.value = field_name;
select.dispatchEvent(new Event("change"));
await nextTick();
await click(target.querySelectorAll(".modal .modal-footer button")[1]);
assert.containsNone(target, ".modal");
}
assert.deepEqual(arg_steps, [
["partner", "datetime", "2024-01-24 16:46:16", true, true, false],
["partner", "reference", {"displayName": "Test", "resId": 1, "resModel": "pony"}, true, true, false],
["partner", "m2o", 1, true, true, false],
]);
});
QUnit.test("set defaults: settings default value for a very long value", async (assert) => {
prepareRegistriesWithCleanup();
patchWithCleanup(odoo, {
debug: true,
});
registry.category("services").add("user", makeFakeUserService());
registry.category("debug").category("form").add("setDefaults", setDefaults);
const serverData = getActionManagerServerData();
serverData.models.partner.fields.description = { string: "Description", type: "html" };
serverData.actions[1234] = {
id: 1234,
xml_id: "action_1234",
name: "Partners",
res_model: "partner",
res_id: 1,
type: "ir.actions.act_window",
views: [[18, "form"]],
};
const fooValue = "12".repeat(250);
serverData.views["partner,18,form"] = `
<form>
<group>
<field name="display_name"/>
<field name="description"/>
<field name="foo"/>
</group>
</form>
`;
serverData.models.partner.records[0].foo = fooValue;
serverData.models.partner.records[0].description = fooValue;
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
if (args.method === "set" && args.model === "ir.default") {
assert.step("setting default");
assert.deepEqual(args.args, ["partner", "foo", fooValue, true, true, false]);
return true;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1234);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
const select = target.querySelector(".modal #formview_default_fields");
const options = Object.fromEntries(
Array.from(select.querySelectorAll("option")).map((option) => [
option.value,
option.textContent,
])
);
assert.deepEqual(options, {
"": "",
display_name: "Display Name = First record",
foo: "Foo = 121212121212121212121212121212121212121212121212121212121...",
description:
"Description = 121212121212121212121212121212121212121212121212121212121...",
});
select.value = "foo";
select.dispatchEvent(new Event("change"));
await nextTick();
await click(target.querySelectorAll(".modal .modal-footer button")[1]);
assert.verifySteps(["setting default"]);
});
});

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

@ -1,91 +0,0 @@
/** @odoo-module **/
import { getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { browser } from "@web/core/browser/browser";
let serverData;
let target;
QUnit.module("Debug > Profiling QWeb", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
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,
},
]);
serverData = {
models: {
partner: {
fields: {
qweb: {
string: "QWeb",
type: "text",
},
},
records: [{ qweb }],
},
"ir.ui.view": {
fields: {
model: { type: "char" },
name: { type: "char" },
type: { type: "char" },
},
records: [
{
id: 1,
name: "formView",
model: "partner",
type: "form",
},
],
},
},
};
setupViewRegistries();
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
});
QUnit.test("profiling qweb view field renders delay and query", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="qweb" widget="profiling_qweb_view" />
</form>`,
});
assert.containsN(target, "[name='qweb'] .ace_gutter .ace_gutter-cell", 3);
assert.containsN(target, "[name='qweb'] .ace_gutter .ace_gutter-cell .o_info", 1);
const infoEl = target.querySelector("[name='qweb'] .ace_gutter .ace_gutter-cell .o_info");
assert.strictEqual(infoEl.querySelector(".o_delay").textContent, "0.1");
assert.strictEqual(infoEl.querySelector(".o_query").textContent, "9");
const header = target.querySelector("[name='qweb'] .o_select_view_profiling");
assert.strictEqual(header.querySelector(".o_delay").textContent, "0.1 ms");
assert.strictEqual(header.querySelector(".o_query").textContent, "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: (params) => expect.step(`close ${JSON.stringify(params)}`),
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 {"dismiss":true}']);
});
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,278 @@
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"]);
});
test("two dialogs, close the first one, closeAll", async () => {
class CustomDialog extends Component {
static components = { Dialog };
static template = xml`<Dialog title="props.title">content</Dialog>`;
static props = ["*"];
}
expect(".o_dialog").toHaveCount(0);
const close = 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"]);
close();
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Sauron");
getService("dialog").closeAll();
await animationFrame();
expect(".o_dialog").toHaveCount(0);
});
test("two dialogs, close the first one twice, then closeAll", 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" },
{
onClose: () => expect.step("close dialog 1"),
}
);
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Hello");
expect(document.body).toHaveClass("modal-open");
const close = getService("dialog").add(
CustomDialog,
{ title: "Sauron" },
{
onClose: () => expect.step("close dialog 2"),
}
);
await animationFrame();
expect(".o_dialog").toHaveCount(2);
expect(queryAllTexts("header .modal-title")).toEqual(["Hello", "Sauron"]);
close();
close();
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Hello");
expect(document.body).toHaveClass("modal-open");
expect.verifySteps(["close dialog 2"]);
getService("dialog").closeAll();
await animationFrame();
expect(".o_dialog").toHaveCount(0);
expect.verifySteps(["close dialog 1"]);
});

View file

@ -1,255 +0,0 @@
/** @odoo-module **/
import { dialogService } from "@web/core/dialog/dialog_service";
import { ErrorDialog } from "@web/core/errors/error_dialogs";
import { errorService } from "@web/core/errors/error_service";
import { registry } from "@web/core/registry";
import { notificationService } from "@web/core/notifications/notification_service";
import { uiService } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { registerCleanup } from "../helpers/cleanup";
import { clearRegistryWithCleanup, makeTestEnv } from "../helpers/mock_env";
import { makeFakeLocalizationService, makeFakeRPCService } from "../helpers/mock_services";
import {
click,
getFixture,
makeDeferred,
mount,
nextTick,
patchWithCleanup,
} from "../helpers/utils";
import { Dialog } from "../../src/core/dialog/dialog";
import { Component, onMounted, xml } from "@odoo/owl";
let env;
let target;
const serviceRegistry = registry.category("services");
const mainComponentRegistry = registry.category("main_components");
class PseudoWebClient extends Component {
setup() {
this.Components = mainComponentRegistry.getEntries();
}
}
PseudoWebClient.template = xml`
<div>
<div>
<t t-foreach="Components" t-as="C" t-key="C[0]">
<t t-component="C[1].Component" t-props="C[1].props"/>
</t>
</div>
</div>
`;
QUnit.module("DialogManager", {
async beforeEach() {
target = getFixture();
clearRegistryWithCleanup(mainComponentRegistry);
serviceRegistry.add("dialog", dialogService);
serviceRegistry.add("ui", uiService);
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("l10n", makeFakeLocalizationService());
env = await makeTestEnv();
},
});
QUnit.test("Simple rendering with a single dialog", async (assert) => {
assert.expect(4);
class CustomDialog extends Component {}
CustomDialog.components = { Dialog };
CustomDialog.template = xml`<Dialog title="'Welcome'">content</Dialog>`;
await mount(PseudoWebClient, target, { env });
assert.containsNone(target, ".o_dialog_container .o_dialog");
env.services.dialog.add(CustomDialog);
await nextTick();
assert.containsOnce(target, ".o_dialog_container .o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Welcome");
await click(target.querySelector(".o_dialog_container .o_dialog footer button"));
assert.containsNone(target, ".o_dialog_container .o_dialog");
});
QUnit.test("Simple rendering and close a single dialog", async (assert) => {
assert.expect(4);
class CustomDialog extends Component {}
CustomDialog.components = { Dialog };
CustomDialog.template = xml`<Dialog title="'Welcome'">content</Dialog>`;
await mount(PseudoWebClient, target, { env });
assert.containsNone(target, ".o_dialog_container .o_dialog");
const removeDialog = env.services.dialog.add(CustomDialog);
await nextTick();
assert.containsOnce(target, ".o_dialog_container .o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Welcome");
removeDialog();
await nextTick();
assert.containsNone(target, ".o_dialog_container .o_dialog");
// 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();
await nextTick();
});
QUnit.test("rendering with two dialogs", async (assert) => {
assert.expect(7);
class CustomDialog extends Component {}
CustomDialog.components = { Dialog };
CustomDialog.template = xml`<Dialog title="props.title">content</Dialog>`;
await mount(PseudoWebClient, target, { env });
assert.containsNone(target, ".o_dialog_container .o_dialog");
env.services.dialog.add(CustomDialog, { title: "Hello" });
await nextTick();
assert.containsOnce(target, ".o_dialog_container .o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Hello");
env.services.dialog.add(CustomDialog, { title: "Sauron" });
await nextTick();
assert.containsN(target, ".o_dialog_container .o_dialog", 2);
assert.deepEqual(
[...target.querySelectorAll("header .modal-title")].map((el) => el.textContent),
["Hello", "Sauron"]
);
await click(target.querySelector(".o_dialog_container .o_dialog footer button"));
assert.containsOnce(target, ".o_dialog_container .o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Sauron");
});
QUnit.test("multiple dialogs can become the UI active element", async (assert) => {
assert.expect(3);
class CustomDialog extends Component {}
CustomDialog.components = { Dialog };
CustomDialog.template = xml`<Dialog title="props.title">content</Dialog>`;
await mount(PseudoWebClient, target, { env });
env.services.dialog.add(CustomDialog, { title: "Hello" });
await nextTick();
let dialogModal = target.querySelector(
".o_dialog_container .o_dialog .modal:not(.o_inactive_modal)"
);
assert.strictEqual(dialogModal, env.services.ui.activeElement);
env.services.dialog.add(CustomDialog, { title: "Sauron" });
await nextTick();
dialogModal = target.querySelector(
".o_dialog_container .o_dialog .modal:not(.o_inactive_modal)"
);
assert.strictEqual(dialogModal, env.services.ui.activeElement);
env.services.dialog.add(CustomDialog, { title: "Rafiki" });
await nextTick();
dialogModal = target.querySelector(
".o_dialog_container .o_dialog .modal:not(.o_inactive_modal)"
);
assert.strictEqual(dialogModal, env.services.ui.activeElement);
});
QUnit.test("Interactions between multiple dialogs", async (assert) => {
assert.expect(14);
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 {}
CustomDialog.components = { Dialog };
CustomDialog.template = xml`<Dialog title="props.title">content</Dialog>`;
await mount(PseudoWebClient, target, { env });
env.services.dialog.add(CustomDialog, { title: "Hello" });
await nextTick();
env.services.dialog.add(CustomDialog, { title: "Sauron" });
await nextTick();
env.services.dialog.add(CustomDialog, { title: "Rafiki" });
await nextTick();
let modals = document.querySelectorAll(".modal");
assert.containsN(target, ".o_dialog", 3);
let res = activity(modals);
assert.deepEqual(res.active, [false, false, true]);
assert.deepEqual(res.names, ["Hello", "Sauron", "Rafiki"]);
assert.hasClass(target.querySelector(".o_dialog_container"), "modal-open");
let lastDialog = modals[modals.length - 1];
lastDialog.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
await nextTick();
modals = document.querySelectorAll(".modal");
assert.containsN(target, ".o_dialog", 2);
res = activity(modals);
assert.deepEqual(res.active, [false, true]);
assert.deepEqual(res.names, ["Hello", "Sauron"]);
assert.hasClass(target.querySelector(".o_dialog_container"), "modal-open");
lastDialog = modals[modals.length - 1];
await click(lastDialog, "footer button");
modals = document.querySelectorAll(".modal");
assert.containsN(target, ".o_dialog", 1);
res = activity(modals);
assert.deepEqual(res.active, [true]);
assert.deepEqual(res.names, ["Hello"]);
assert.hasClass(target.querySelector(".o_dialog_container"), "modal-open");
lastDialog = modals[modals.length - 1];
await click(lastDialog, "footer button");
assert.containsNone(target, ".o_dialog_container .modal");
assert.containsOnce(target, ".o_dialog_container");
});
QUnit.test("dialog component crashes", async (assert) => {
assert.expect(4);
class FailingDialog extends Component {
setup() {
throw new Error("Some Error");
}
}
FailingDialog.components = { Dialog };
FailingDialog.template = xml`<Dialog title="'Error'">content</Dialog>`;
const prom = makeDeferred();
patchWithCleanup(ErrorDialog.prototype, {
setup() {
this._super();
onMounted(() => {
prom.resolve();
});
},
});
const handler = (ev) => {
assert.step("error");
// need to preventDefault to remove error from console (so python test pass)
ev.preventDefault();
};
window.addEventListener("unhandledrejection", handler);
registerCleanup(() => window.removeEventListener("unhandledrejection", handler));
patchWithCleanup(QUnit, {
onUnhandledRejection: () => {},
});
const rpc = makeFakeRPCService();
serviceRegistry.add("rpc", rpc);
serviceRegistry.add("notification", notificationService);
serviceRegistry.add("error", errorService);
await mount(PseudoWebClient, target, { env });
env.services.dialog.add(FailingDialog);
await prom;
assert.verifySteps(["error"]);
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal .o_dialog_error");
});

View file

@ -1,299 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { uiService } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { Dialog } from "@web/core/dialog/dialog";
import { makeTestEnv } from "../helpers/mock_env";
import { click, destroy, getFixture, mount } from "../helpers/utils";
import { makeFakeDialogService } from "../helpers/mock_services";
import { Component, useState, onMounted, xml } from "@odoo/owl";
const serviceRegistry = registry.category("services");
let parent;
let target;
async function makeDialogTestEnv() {
const env = await makeTestEnv();
env.dialogData = {
isActive: true,
close: () => {},
};
return env;
}
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async () => {
target = getFixture();
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("ui", uiService);
serviceRegistry.add("dialog", makeFakeDialogService());
});
hooks.afterEach(() => {
if (parent) {
parent = undefined;
}
});
QUnit.module("Dialog");
QUnit.test("simple rendering", async function (assert) {
assert.expect(8);
class Parent extends Component {}
Parent.components = { Dialog };
Parent.template = xml`
<Dialog title="'Wow(l) Effect'">
Hello!
</Dialog>
`;
const env = await makeDialogTestEnv();
parent = await mount(Parent, target, { env });
assert.containsOnce(target, ".o_dialog");
assert.containsOnce(
target,
".o_dialog header .modal-title",
"the header is rendered by default"
);
assert.strictEqual(
target.querySelector("header .modal-title").textContent,
"Wow(l) Effect"
);
assert.containsOnce(target, ".o_dialog main", "a dialog has always a main node");
assert.strictEqual(target.querySelector("main").textContent, " Hello! ");
assert.containsOnce(target, ".o_dialog footer", "the footer is rendered by default");
assert.containsOnce(
target,
".o_dialog footer button",
"the footer is rendered with a single button 'Ok' by default"
);
assert.strictEqual(target.querySelector("footer button").textContent, "Ok");
});
QUnit.test("simple rendering with two dialogs", async function (assert) {
assert.expect(3);
class Parent extends Component {}
Parent.template = xml`
<div>
<Dialog title="'First Title'">
Hello!
</Dialog>
<Dialog title="'Second Title'">
Hello again!
</Dialog>
</div>
`;
Parent.components = { Dialog };
const env = await makeDialogTestEnv();
parent = await mount(Parent, target, { env });
assert.containsN(target, ".o_dialog", 2);
assert.deepEqual(
[...target.querySelectorAll("header .modal-title")].map((el) => el.textContent),
["First Title", "Second Title"]
);
assert.deepEqual(
[...target.querySelectorAll(".o_dialog .modal-body")].map((el) => el.textContent),
[" Hello! ", " Hello again! "]
);
});
QUnit.test("click on the button x triggers the service close", async function (assert) {
assert.expect(3);
const env = await makeDialogTestEnv();
env.dialogData.close = () => assert.step("close");
class Parent extends Component {}
Parent.template = xml`
<Dialog>
Hello!
</Dialog>
`;
Parent.components = { Dialog };
parent = await mount(Parent, target, { env });
assert.containsOnce(target, ".o_dialog");
await click(target, ".o_dialog header button.btn-close");
assert.verifySteps(["close"]);
});
QUnit.test(
"click on the default footer button triggers the service close",
async function (assert) {
const env = await makeDialogTestEnv();
env.dialogData.close = () => assert.step("close");
assert.expect(3);
class Parent extends Component {}
Parent.template = xml`
<Dialog>
Hello!
</Dialog>
`;
Parent.components = { Dialog };
parent = await mount(Parent, target, { env });
assert.containsOnce(target, ".o_dialog");
await click(target, ".o_dialog footer button");
assert.verifySteps(["close"]);
}
);
QUnit.test("render custom footer buttons is possible", async function (assert) {
assert.expect(2);
class SimpleButtonsDialog extends Component {}
SimpleButtonsDialog.components = { Dialog };
SimpleButtonsDialog.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>
`;
class Parent extends Component {
setup() {
super.setup();
this.state = useState({
displayDialog: true,
});
}
}
Parent.template = xml`
<div>
<SimpleButtonsDialog/>
</div>
`;
Parent.components = { SimpleButtonsDialog };
const env = await makeDialogTestEnv();
parent = await mount(Parent, target, { env });
assert.containsOnce(target, ".o_dialog");
assert.containsN(target, ".o_dialog footer button", 2);
});
QUnit.test("embed an arbitrary component in a dialog is possible", async function (assert) {
assert.expect(6);
class SubComponent extends Component {
_onClick() {
assert.step("subcomponent-clicked");
this.props.onClicked();
}
}
SubComponent.template = xml`
<div class="o_subcomponent" t-esc="props.text" t-on-click="_onClick"/>
`;
class Parent extends Component {
_onSubcomponentClicked() {
assert.step("message received by parent");
}
}
Parent.components = { Dialog, SubComponent };
Parent.template = xml`
<Dialog>
<SubComponent text="'Wow(l) Effect'" onClicked="_onSubcomponentClicked"/>
</Dialog>
`;
const env = await makeDialogTestEnv();
parent = await mount(Parent, target, { env });
assert.containsOnce(target, ".o_dialog");
assert.containsOnce(target, ".o_dialog main .o_subcomponent");
assert.strictEqual(target.querySelector(".o_subcomponent").textContent, "Wow(l) Effect");
await click(target.querySelector(".o_subcomponent"));
assert.verifySteps(["subcomponent-clicked", "message received by parent"]);
});
QUnit.test("dialog without header/footer", async function (assert) {
assert.expect(4);
class Parent extends Component {}
Parent.template = xml`
<Dialog header="false" footer="false">content</Dialog>
`;
const env = await makeDialogTestEnv();
Parent.components = { Dialog };
parent = await mount(Parent, target, { env });
assert.containsOnce(target, ".o_dialog");
assert.containsNone(target, ".o_dialog header");
assert.containsOnce(target, "main", "a dialog has always a main node");
assert.containsNone(target, ".o_dialog footer");
});
QUnit.test("dialog size can be chosen", async function (assert) {
assert.expect(5);
class Parent extends Component {}
Parent.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>`;
Parent.components = { Dialog };
const env = await makeDialogTestEnv();
parent = await mount(Parent, target, { env });
assert.containsN(target, ".o_dialog", 4);
assert.containsOnce(
target,
target.querySelectorAll(".o_dialog .modal-dialog.modal-xl .xl")
);
assert.containsOnce(
target,
target.querySelectorAll(".o_dialog .modal-dialog.modal-lg .lg")
);
assert.containsOnce(
target,
target.querySelectorAll(".o_dialog .modal-dialog.modal-md .md")
);
assert.containsOnce(
target,
target.querySelectorAll(".o_dialog .modal-dialog.modal-sm .sm")
);
});
QUnit.test("dialog can be rendered on fullscreen", async function (assert) {
assert.expect(2);
class Parent extends Component {}
Parent.template = xml`
<Dialog fullscreen="true">content</Dialog>
`;
Parent.components = { Dialog };
const env = await makeDialogTestEnv();
parent = await mount(Parent, target, { env });
assert.containsOnce(target, ".o_dialog");
assert.hasClass(target.querySelector(".o_dialog .modal"), "o_modal_full");
});
QUnit.test("can be the UI active element", async function (assert) {
assert.expect(4);
class Parent extends Component {
setup() {
this.ui = useService("ui");
assert.strictEqual(
this.ui.activeElement,
document,
"UI active element should be the default (document) as Parent is not mounted yet"
);
onMounted(() => {
assert.containsOnce(target, ".modal");
assert.strictEqual(
this.ui.activeElement,
target.querySelector(".modal"),
"UI active element should be the dialog modal"
);
});
}
}
const env = await makeDialogTestEnv();
Parent.template = xml`<Dialog>content</Dialog>`;
Parent.components = { Dialog };
const parent = await mount(Parent, target, { env });
destroy(parent);
assert.strictEqual(
env.services.ui.activeElement,
document,
"UI owner should be reset to the default (document)"
);
});
});

View file

@ -0,0 +1,597 @@
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 and not likes", () => {
expect.assertions(34);
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(true);
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(true);
expect(new Domain([["a", "not =like", "%value"]]).contains({ a: "some value" })).toBe(false);
expect(new Domain([["a", "not =like", "%value"]]).contains({ a: "Some Value" })).not.toBe(false);
expect(new Domain([["a", "not =ilike", "%value"]]).contains({ a: "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: "Some Value" })).toBe(false);
expect(new Domain([["a", "not =ilike", "%value"]]).contains({ a: false })).toBe(true);
});
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",
};

View file

@ -1,650 +0,0 @@
/** @odoo-module **/
import { DomainSelector } from "@web/core/domain_selector/domain_selector";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { ormService } from "@web/core/orm_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { viewService } from "@web/views/view_service";
import { makeTestEnv } from "../helpers/mock_env";
import { click, editInput, editSelect, getFixture, mount, triggerEvent } from "../helpers/utils";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import { Component, xml } from "@odoo/owl";
let serverData;
let target;
async function mountComponent(Component, params = {}) {
const env = await makeTestEnv({ serverData, mockRPC: params.mockRPC });
await mount(MainComponentsContainer, target, { env });
return mount(Component, target, { env, props: params.props || {} });
}
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async () => {
serverData = {
models: {
partner: {
fields: {
foo: { string: "Foo", type: "char", searchable: true },
bar: { string: "Bar", type: "boolean", searchable: true },
product_id: {
string: "Product",
type: "many2one",
relation: "product",
searchable: true,
},
datetime: { string: "Date Time", type: "datetime", searchable: true },
},
records: [
{ id: 1, foo: "yop", bar: true, product_id: 37 },
{ id: 2, foo: "blip", bar: true, product_id: false },
{ id: 4, foo: "abc", bar: false, product_id: 41 },
],
onchanges: {},
},
product: {
fields: {
name: { string: "Product Name", type: "char", searchable: true },
},
records: [
{ id: 37, display_name: "xphone" },
{ id: 41, display_name: "xpad" },
],
},
},
};
registry.category("services").add("popover", popoverService);
registry.category("services").add("orm", ormService);
registry.category("services").add("ui", uiService);
registry.category("services").add("hotkey", hotkeyService);
registry.category("services").add("localization", makeFakeLocalizationService());
registry.category("services").add("view", viewService);
target = getFixture();
});
QUnit.module("DomainSelector");
QUnit.test("creating a domain from scratch", async (assert) => {
assert.expect(12);
class Parent extends Component {
setup() {
this.value = "[]";
}
onUpdate(newValue) {
this.value = newValue;
this.render();
}
}
Parent.components = { DomainSelector };
Parent.template = xml`
<DomainSelector
resModel="'partner'"
value="value"
readonly="false"
isDebugMode="true"
update="(newValue) => this.onUpdate(newValue)"
/>
`;
// Create the domain selector and its mock environment
await mountComponent(Parent);
// As we gave an empty domain, there should be a visible button to add
// the first domain part
assert.containsOnce(
target,
".o_domain_add_first_node_button",
"there should be a button to create first domain element"
);
// Clicking on the button should add a visible field selector in the
// widget so that the user can change the field chain
await click(target, ".o_domain_add_first_node_button");
assert.containsOnce(target, ".o_field_selector", "there should be a field selector");
// Focusing the field selector input should open a field selector popover
await click(target, ".o_field_selector");
assert.containsOnce(
document.body,
".o_field_selector_popover",
"field selector popover should be visible"
);
// The field selector popover should contain the list of "partner"
// fields. "Bar" should be among them. "Bar" result li will display the
// name of the field and some debug info.
assert.strictEqual(
document.body.querySelector(".o_field_selector_popover li").textContent,
"Barbar (boolean)",
"field selector popover should contain the 'Bar' field"
);
// Clicking the "Bar" field should change the internal domain and this
// should be displayed in the debug textarea
await click(document.body.querySelector(".o_field_selector_popover li"));
assert.containsOnce(target, "textarea.o_domain_debug_input");
assert.strictEqual(
target.querySelector(".o_domain_debug_input").value,
`[("bar", "=", True)]`,
"the domain input should contain a domain with 'bar'"
);
// There should be a "+" button to add a domain part; clicking on it
// should add the default "['id', '=', 1]" domain
assert.containsOnce(target, ".fa-plus-circle", "there should be a '+' button");
await click(target, ".fa-plus-circle");
assert.strictEqual(
target.querySelector(".o_domain_debug_input").value,
`["&", ("bar", "=", True), ("id", "=", 1)]`,
"the domain input should contain a domain with 'bar' and 'id'"
);
// There should be two "..." buttons to add a domain group; clicking on
// the first one, should add this group with defaults "['id', '=', 1]"
// domains and the "|" operator
assert.containsN(target, ".fa-ellipsis-h", 2, "there should be two '...' buttons");
await click(target.querySelector(".fa-ellipsis-h"));
assert.strictEqual(
target.querySelector(".o_domain_debug_input").value,
`["&", ("bar", "=", True), "&", "|", ("id", "=", 1), ("id", "=", 1), ("id", "=", 1)]`,
"the domain input should contain a domain with 'bar', 'id' and a subgroup"
);
// There should be five "-" buttons to remove domain part; clicking on
// the two last ones, should leave a domain with only the "bar" and
// "foo" fields, with the initial "&" operator
assert.containsN(
target,
".o_domain_delete_node_button",
5,
"there should be five 'x' buttons"
);
let buttons = target.querySelectorAll(".o_domain_delete_node_button");
await click(buttons[buttons.length - 1]);
buttons = target.querySelectorAll(".o_domain_delete_node_button");
await click(buttons[buttons.length - 1]);
assert.strictEqual(
target.querySelector(".o_domain_debug_input").value,
`["&", ("bar", "=", True), ("id", "=", 1)]`,
"the domain input should contain a domain with 'bar' and 'id'"
);
});
QUnit.test("building a domain with a datetime", async (assert) => {
assert.expect(4);
class Parent extends Component {
setup() {
this.value = `[("datetime", "=", "2017-03-27 15:42:00")]`;
}
onUpdate(newValue) {
assert.strictEqual(
newValue,
`[("datetime", "=", "2017-02-26 15:42:00")]`,
"datepicker value should have changed"
);
this.value = newValue;
this.render();
}
}
Parent.components = { DomainSelector };
Parent.template = xml`
<DomainSelector
resModel="'partner'"
value="value"
readonly="false"
isDebugMode="true"
update="(newValue) => this.onUpdate(newValue)"
/>
`;
// Create the domain selector and its mock environment
await mountComponent(Parent);
// Check that there is a datepicker to choose the date
assert.containsOnce(target, ".o_datepicker", "there should be a datepicker");
// The input field should display the date and time in the user's timezone
assert.equal(target.querySelector(".o_datepicker_input").value, "03/27/2017 16:42:00");
// Change the date in the datepicker
await click(target, ".o_datepicker_input");
await click(
document.body.querySelector(
`.bootstrap-datetimepicker-widget :not(.today)[data-action="selectDay"]`
)
); // => February 26th
await click(
document.body.querySelector(`.bootstrap-datetimepicker-widget a[data-action="close"]`)
);
// The input field should display the date and time in the user's timezone
assert.equal(target.querySelector(".o_datepicker_input").value, "02/26/2017 16:42:00");
});
QUnit.test("building a domain with a datetime: context_today()", async (assert) => {
// Create the domain selector and its mock environment
await mountComponent(DomainSelector, {
props: {
resModel: "partner",
value: `[("datetime", "=", context_today())]`,
readonly: false,
update: () => {
assert.step("SHOULD NEVER BE CALLED");
},
},
});
// Check that there is a datepicker to choose the date
assert.containsOnce(target, ".o_datepicker", "there should be a datepicker");
// The input field should display that the date is invalid
assert.equal(target.querySelector(".o_datepicker_input").value, "Invalid DateTime");
// Open and close the datepicker
await click(target, ".o_datepicker_input");
await click(
document.body.querySelector(`.bootstrap-datetimepicker-widget [data-action=close]`)
);
// The input field should continue displaying 'Invalid DateTime'.
// The value is still invalid.
assert.equal(target.querySelector(".o_datepicker_input").value, "Invalid DateTime");
assert.verifySteps([]);
});
QUnit.test("building a domain with a m2o without following the relation", async (assert) => {
assert.expect(1);
// Create the domain selector and its mock environment
await mountComponent(DomainSelector, {
props: {
resModel: "partner",
value: `[("product_id", "ilike", 1)]`,
readonly: false,
isDebugMode: true,
update: (newValue) => {
assert.strictEqual(
newValue,
`[("product_id", "ilike", "pad")]`,
"string should have been allowed as m2o value"
);
},
},
});
const input = target.querySelector(".o_domain_leaf_value_input");
input.value = "pad";
await triggerEvent(input, null, "change");
});
QUnit.test("editing a domain with `parent` key", async (assert) => {
// Create the domain selector and its mock environment
await mountComponent(DomainSelector, {
props: {
resModel: "product",
value: `[("name", "=", parent.foo)]`,
readonly: false,
isDebugMode: true,
},
});
assert.strictEqual(
target.lastElementChild.textContent,
" This domain is not supported. Reset domain",
"an error message should be displayed because of the `parent` key"
);
assert.containsOnce(target, "button:contains(Reset domain)");
});
QUnit.test("creating a domain with a default option", async (assert) => {
assert.expect(1);
// Create the domain selector and its mock environment
await mountComponent(DomainSelector, {
props: {
resModel: "partner",
value: "[]",
readonly: false,
isDebugMode: true,
defaultLeafValue: ["foo", "=", "kikou"],
update: (newValue) => {
assert.strictEqual(
newValue,
`[("foo", "=", "kikou")]`,
"the domain input should contain the default domain"
);
},
},
});
// Clicking on the button should add a visible field selector in the
// widget so that the user can change the field chain
await click(target, ".o_domain_add_first_node_button");
});
QUnit.test("edit a domain with the debug textarea", async (assert) => {
assert.expect(5);
let newValue;
class Parent extends Component {
setup() {
this.value = `[("product_id", "ilike", 1)]`;
}
onUpdate(value, fromDebug) {
assert.strictEqual(value, newValue);
assert.ok(fromDebug);
}
}
Parent.components = { DomainSelector };
Parent.template = xml`
<DomainSelector
value="value"
resModel="'partner'"
readonly="false"
isDebugMode="true"
update="(...args) => this.onUpdate(...args)"
/>
`;
// Create the domain selector and its mock environment
await mountComponent(Parent);
assert.containsOnce(
target,
".o_domain_node.o_domain_leaf",
"should have a single domain node"
);
newValue = `
[
['product_id', 'ilike', 1],
['id', '=', 0]
]`;
const input = target.querySelector(".o_domain_debug_input");
input.value = newValue;
await triggerEvent(input, null, "change");
assert.strictEqual(
target.querySelector(".o_domain_debug_input").value,
newValue,
"the domain should not have been formatted"
);
assert.containsOnce(
target,
".o_domain_node.o_domain_leaf",
"should still have a single domain node"
);
});
QUnit.test(
"set [(1, '=', 1)] or [(0, '=', 1)] as domain with the debug textarea",
async (assert) => {
assert.expect(15);
let newValue;
class Parent extends Component {
setup() {
this.value = `[("product_id", "ilike", 1)]`;
}
onUpdate(value, fromDebug) {
this.value = value;
assert.strictEqual(value, newValue);
assert.ok(fromDebug);
this.render();
}
}
Parent.components = { DomainSelector };
Parent.template = xml`
<DomainSelector
value="value"
resModel="'partner'"
readonly="false"
isDebugMode="true"
update="(...args) => this.onUpdate(...args)"
/>
`;
// Create the domain selector and its mock environment
await mountComponent(Parent);
assert.containsOnce(
target,
".o_domain_node.o_domain_leaf",
"should have a single domain node"
);
newValue = `[(1, "=", 1)]`;
let input = target.querySelector(".o_domain_debug_input");
input.value = newValue;
await triggerEvent(input, null, "change");
assert.strictEqual(
target.querySelector(".o_domain_debug_input").value,
newValue,
"the domain should not have been formatted"
);
assert.containsOnce(
target,
".o_domain_node.o_domain_leaf",
"should still have a single domain node"
);
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "1");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "0"); // option "="
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "1");
newValue = `[(0, "=", 1)]`;
input = target.querySelector(".o_domain_debug_input");
input.value = newValue;
await triggerEvent(input, null, "change");
assert.strictEqual(
target.querySelector(".o_domain_debug_input").value,
newValue,
"the domain should not have been formatted"
);
assert.containsOnce(
target,
".o_domain_node.o_domain_leaf",
"should still have a single domain node"
);
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "0");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "0"); // option "="
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "1");
}
);
QUnit.test("operator fallback", async (assert) => {
await mountComponent(DomainSelector, {
props: {
resModel: "partner",
value: "[['foo', 'like', 'kikou']]",
},
});
assert.strictEqual(target.querySelector(".o_domain_leaf").textContent, `Foo like "kikou"`);
});
QUnit.test("operator fallback in edit mode", async (assert) => {
registry.category("domain_selector/operator").add("test", {
category: "test",
label: "test",
value: "test",
onDidChange: () => null,
matches: ({ operator }) => operator === "test",
hideValue: true,
});
await mountComponent(DomainSelector, {
props: {
readonly: false,
resModel: "partner",
value: "[['foo', 'test', 'kikou']]",
},
});
// check that the DomainSelector does not crash
assert.containsOnce(target, ".o_domain_selector");
assert.containsN(target, ".o_domain_leaf_edition > div", 2, "value should be hidden");
});
QUnit.test("cache fields_get", async (assert) => {
await mountComponent(DomainSelector, {
mockRPC(route, { method }) {
if (method === "fields_get") {
assert.step("fields_get");
}
},
props: {
readonly: false,
resModel: "partner",
value: "['&', ['foo', '=', 'kikou'], ['bar', '=', 'true']]",
},
});
assert.verifySteps(["fields_get"]);
});
QUnit.test("selection field with operator change from 'is set' to '='", async (assert) => {
serverData.models.partner.fields.state = {
string: "State",
type: "selection",
selection: [
["abc", "ABC"],
["def", "DEF"],
["ghi", "GHI"],
],
};
class Parent extends Component {
setup() {
this.value = `[['state', '!=', false]]`;
}
onUpdate(newValue) {
this.value = newValue;
this.render();
}
}
Parent.components = { DomainSelector };
Parent.template = xml`
<DomainSelector
resModel="'partner'"
value="value"
readonly="false"
update="(newValue) => this.onUpdate(newValue)"
/>
`;
// Create the domain selector and its mock environment
await mountComponent(Parent);
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "State");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "2"); // option "!="
await editSelect(target, ".o_domain_leaf_operator_select", 0);
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "State");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "0"); // option "="
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "abc");
});
QUnit.test("show correct operator", async (assert) => {
serverData.models.partner.fields.state = {
string: "State",
type: "selection",
selection: [
["abc", "ABC"],
["def", "DEF"],
["ghi", "GHI"],
],
};
await mountComponent(DomainSelector, {
props: {
resModel: "partner",
value: `[['state', 'in', ['abc']]]`,
readonly: false,
},
});
const select = target.querySelector(".o_domain_leaf_operator_select");
assert.strictEqual(select.options[select.options.selectedIndex].text, "in");
});
QUnit.test("multi selection", async (assert) => {
serverData.models.partner.fields.state = {
string: "State",
type: "selection",
selection: [
["a", "A"],
["b", "B"],
["c", "C"],
],
};
class Parent extends Component {
setup() {
this.value = `[("state", "in", ["a", "b", "c"])]`;
}
onUpdate(newValue) {
this.value = newValue;
this.render();
}
}
Parent.components = { DomainSelector };
Parent.template = xml`
<DomainSelector
resModel="'partner'"
value="value"
readonly="false"
update="(newValue) => this.onUpdate(newValue)"
/>
`;
// Create the domain selector and its mock environment
const comp = await mountComponent(Parent);
assert.containsOnce(target, ".o_domain_leaf_value_input");
assert.strictEqual(comp.value, `[("state", "in", ["a", "b", "c"])]`);
assert.strictEqual(
target.querySelector(".o_domain_leaf_value_input").value,
`["a", "b", "c"]`
);
await editInput(target, ".o_domain_leaf_value_input", `[]`);
assert.strictEqual(comp.value, `[("state", "in", [])]`);
await editInput(target, ".o_domain_leaf_value_input", `["b"]`);
assert.strictEqual(comp.value, `[("state", "in", ["b"])]`);
});
QUnit.test("updating path should also update operator if invalid", async (assert) => {
await mountComponent(DomainSelector, {
props: {
resModel: "partner",
value: `[("id", "<", 0)]`,
readonly: false,
update: (domain) => {
assert.strictEqual(domain, `[("foo", "=", "")]`);
},
},
});
await click(target, ".o_field_selector");
await click(target, ".o_field_selector_popover .o_field_selector_item[data-name=foo]");
});
QUnit.test("do not crash with connector '!'", async (assert) => {
class Parent extends Component {
setup() {
this.domain = `["!", ("foo", "=", "abc")]`;
}
}
Parent.components = { DomainSelector };
Parent.template = xml`<DomainSelector resModel="'partner'" value="domain" readonly="false"/>`;
await mountComponent(Parent);
assert.containsOnce(target, ".o_domain_node.o_domain_leaf");
});
});

View file

@ -1,562 +0,0 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { PyDate } from "../../src/core/py_js/py_date";
import { patchWithCleanup } from "../helpers/utils";
QUnit.module("domain", {}, () => {
// ---------------------------------------------------------------------------
// Basic properties
// ---------------------------------------------------------------------------
QUnit.module("Basic Properties");
QUnit.test("empty", function (assert) {
assert.ok(new Domain([]).contains({}));
assert.strictEqual(new Domain([]).toString(), "[]");
assert.deepEqual(new Domain([]).toList(), []);
});
QUnit.test("undefined domain", function (assert) {
assert.ok(new Domain(undefined).contains({}));
assert.strictEqual(new Domain(undefined).toString(), "[]");
assert.deepEqual(new Domain(undefined).toList(), []);
});
QUnit.test("simple condition", function (assert) {
assert.ok(new Domain([["a", "=", 3]]).contains({ a: 3 }));
assert.notOk(new Domain([["a", "=", 3]]).contains({ a: 5 }));
assert.strictEqual(new Domain([["a", "=", 3]]).toString(), `[("a", "=", 3)]`);
assert.deepEqual(new Domain([["a", "=", 3]]).toList(), [["a", "=", 3]]);
});
QUnit.test("can be created from domain", function (assert) {
const domain = new Domain([["a", "=", 3]]);
assert.strictEqual(new Domain(domain).toString(), `[("a", "=", 3)]`);
});
QUnit.test("basic", function (assert) {
const record = {
a: 3,
group_method: "line",
select1: "day",
rrule_type: "monthly",
};
assert.ok(new Domain([["a", "=", 3]]).contains(record));
assert.notOk(new Domain([["a", "=", 5]]).contains(record));
assert.ok(new Domain([["group_method", "!=", "count"]]).contains(record));
assert.ok(
new Domain([
["select1", "=", "day"],
["rrule_type", "=", "monthly"],
]).contains(record)
);
});
QUnit.test("support of '=?' operator", function (assert) {
const record = { a: 3 };
assert.ok(new Domain([["a", "=?", null]]).contains(record));
assert.ok(new Domain([["a", "=?", false]]).contains(record));
assert.notOk(new Domain(["!", ["a", "=?", false]]).contains(record));
assert.notOk(new Domain([["a", "=?", 1]]).contains(record));
assert.ok(new Domain([["a", "=?", 3]]).contains(record));
assert.notOk(new Domain(["!", ["a", "=?", 3]]).contains(record));
});
QUnit.test("or", function (assert) {
const currentDomain = [
"|",
["section_id", "=", 42],
"|",
["user_id", "=", 3],
["member_ids", "in", [3]],
];
const record = {
section_id: null,
user_id: null,
member_ids: null,
};
assert.ok(new Domain(currentDomain).contains({ ...record, section_id: 42 }));
assert.ok(new Domain(currentDomain).contains({ ...record, user_id: 3 }));
assert.ok(new Domain(currentDomain).contains({ ...record, member_ids: 3 }));
});
QUnit.test("and", function (assert) {
const domain = new Domain(["&", "&", ["a", "=", 1], ["b", "=", 2], ["c", "=", 3]]);
assert.ok(domain.contains({ a: 1, b: 2, c: 3 }));
assert.notOk(domain.contains({ a: -1, b: 2, c: 3 }));
assert.notOk(domain.contains({ a: 1, b: -1, c: 3 }));
assert.notOk(domain.contains({ a: 1, b: 2, c: -1 }));
});
QUnit.test("not", function (assert) {
const record = {
a: 5,
group_method: "line",
};
assert.ok(new Domain(["!", ["a", "=", 3]]).contains(record));
assert.ok(new Domain(["!", ["group_method", "=", "count"]]).contains(record));
});
QUnit.test("like, =like, ilike and =ilike", function (assert) {
assert.expect(16);
assert.ok(new Domain([["a", "like", "value"]]).contains({ a: "value" }));
assert.ok(new Domain([["a", "like", "value"]]).contains({ a: "some value" }));
assert.notOk(new Domain([["a", "like", "value"]]).contains({ a: "Some Value" }));
assert.notOk(new Domain([["a", "like", "value"]]).contains({ a: false }));
assert.ok(new Domain([["a", "=like", "%value"]]).contains({ a: "value" }));
assert.ok(new Domain([["a", "=like", "%value"]]).contains({ a: "some value" }));
assert.notOk(new Domain([["a", "=like", "%value"]]).contains({ a: "Some Value" }));
assert.notOk(new Domain([["a", "=like", "%value"]]).contains({ a: false }));
assert.ok(new Domain([["a", "ilike", "value"]]).contains({ a: "value" }));
assert.ok(new Domain([["a", "ilike", "value"]]).contains({ a: "some value" }));
assert.ok(new Domain([["a", "ilike", "value"]]).contains({ a: "Some Value" }));
assert.notOk(new Domain([["a", "ilike", "value"]]).contains({ a: false }));
assert.ok(new Domain([["a", "=ilike", "%value"]]).contains({ a: "value" }));
assert.ok(new Domain([["a", "=ilike", "%value"]]).contains({ a: "some value" }));
assert.ok(new Domain([["a", "=ilike", "%value"]]).contains({ a: "Some Value" }));
assert.notOk(new Domain([["a", "=ilike", "%value"]]).contains({ a: false }));
});
QUnit.test("complex domain", function (assert) {
const domain = new Domain(["&", "!", ["a", "=", 1], "|", ["a", "=", 2], ["a", "=", 3]]);
assert.notOk(domain.contains({ a: 1 }));
assert.ok(domain.contains({ a: 2 }));
assert.ok(domain.contains({ a: 3 }));
assert.notOk(domain.contains({ a: 4 }));
});
QUnit.test("toList", function (assert) {
assert.deepEqual(new Domain([]).toList(), []);
assert.deepEqual(new Domain([["a", "=", 3]]).toList(), [["a", "=", 3]]);
assert.deepEqual(
new Domain([
["a", "=", 3],
["b", "!=", "4"],
]).toList(),
["&", ["a", "=", 3], ["b", "!=", "4"]]
);
assert.deepEqual(new Domain(["!", ["a", "=", 3]]).toList(), ["!", ["a", "=", 3]]);
});
QUnit.test("toString", function (assert) {
assert.strictEqual(new Domain([]).toString(), `[]`);
assert.strictEqual(new Domain([["a", "=", 3]]).toString(), `[("a", "=", 3)]`);
assert.strictEqual(
new Domain([
["a", "=", 3],
["b", "!=", "4"],
]).toString(),
`["&", ("a", "=", 3), ("b", "!=", "4")]`
);
assert.strictEqual(new Domain(["!", ["a", "=", 3]]).toString(), `["!", ("a", "=", 3)]`);
assert.strictEqual(new Domain([["name", "=", null]]).toString(), '[("name", "=", None)]');
assert.strictEqual(new Domain([["name", "=", false]]).toString(), '[("name", "=", False)]');
assert.strictEqual(new Domain([["name", "=", true]]).toString(), '[("name", "=", True)]');
assert.strictEqual(
new Domain([["name", "=", "null"]]).toString(),
'[("name", "=", "null")]'
);
assert.strictEqual(
new Domain([["name", "=", "false"]]).toString(),
'[("name", "=", "false")]'
);
assert.strictEqual(
new Domain([["name", "=", "true"]]).toString(),
'[("name", "=", "true")]'
);
assert.strictEqual(new Domain().toString(), "[]");
assert.strictEqual(
new Domain([["name", "in", [true, false]]]).toString(),
'[("name", "in", [True, False])]'
);
assert.strictEqual(
new Domain([["name", "in", [null]]]).toString(),
'[("name", "in", [None])]'
);
assert.strictEqual(
new Domain([["name", "in", ["foo", "bar"]]]).toString(),
'[("name", "in", ["foo", "bar"])]'
);
assert.strictEqual(
new Domain([["name", "in", [1, 2]]]).toString(),
'[("name", "in", [1, 2])]'
);
assert.strictEqual(
new Domain(["&", ["name", "=", "foo"], ["type", "=", "bar"]]).toString(),
'["&", ("name", "=", "foo"), ("type", "=", "bar")]'
);
assert.strictEqual(
new Domain(["|", ["name", "=", "foo"], ["type", "=", "bar"]]).toString(),
'["|", ("name", "=", "foo"), ("type", "=", "bar")]'
);
assert.strictEqual(new Domain().toString(), "[]");
// string domains are only reformatted
assert.strictEqual(
new Domain('[("name","ilike","foo")]').toString(),
'[("name", "ilike", "foo")]'
);
});
QUnit.test("implicit &", function (assert) {
const domain = new Domain([
["a", "=", 3],
["b", "=", 4],
]);
assert.notOk(domain.contains({}));
assert.ok(domain.contains({ a: 3, b: 4 }));
assert.notOk(domain.contains({ a: 3, b: 5 }));
});
QUnit.test("comparison operators", function (assert) {
assert.ok(new Domain([["a", "=", 3]]).contains({ a: 3 }));
assert.notOk(new Domain([["a", "=", 3]]).contains({ a: 4 }));
assert.strictEqual(new Domain([["a", "=", 3]]).toString(), `[("a", "=", 3)]`);
assert.ok(new Domain([["a", "==", 3]]).contains({ a: 3 }));
assert.notOk(new Domain([["a", "==", 3]]).contains({ a: 4 }));
assert.strictEqual(new Domain([["a", "==", 3]]).toString(), `[("a", "==", 3)]`);
assert.notOk(new Domain([["a", "!=", 3]]).contains({ a: 3 }));
assert.ok(new Domain([["a", "!=", 3]]).contains({ a: 4 }));
assert.strictEqual(new Domain([["a", "!=", 3]]).toString(), `[("a", "!=", 3)]`);
assert.notOk(new Domain([["a", "<>", 3]]).contains({ a: 3 }));
assert.ok(new Domain([["a", "<>", 3]]).contains({ a: 4 }));
assert.strictEqual(new Domain([["a", "<>", 3]]).toString(), `[("a", "<>", 3)]`);
assert.notOk(new Domain([["a", "<", 3]]).contains({ a: 5 }));
assert.notOk(new Domain([["a", "<", 3]]).contains({ a: 3 }));
assert.ok(new Domain([["a", "<", 3]]).contains({ a: 2 }));
assert.strictEqual(new Domain([["a", "<", 3]]).toString(), `[("a", "<", 3)]`);
assert.notOk(new Domain([["a", "<=", 3]]).contains({ a: 5 }));
assert.ok(new Domain([["a", "<=", 3]]).contains({ a: 3 }));
assert.ok(new Domain([["a", "<=", 3]]).contains({ a: 2 }));
assert.strictEqual(new Domain([["a", "<=", 3]]).toString(), `[("a", "<=", 3)]`);
assert.ok(new Domain([["a", ">", 3]]).contains({ a: 5 }));
assert.notOk(new Domain([["a", ">", 3]]).contains({ a: 3 }));
assert.notOk(new Domain([["a", ">", 3]]).contains({ a: 2 }));
assert.strictEqual(new Domain([["a", ">", 3]]).toString(), `[("a", ">", 3)]`);
assert.ok(new Domain([["a", ">=", 3]]).contains({ a: 5 }));
assert.ok(new Domain([["a", ">=", 3]]).contains({ a: 3 }));
assert.notOk(new Domain([["a", ">=", 3]]).contains({ a: 2 }));
assert.strictEqual(new Domain([["a", ">=", 3]]).toString(), `[("a", ">=", 3)]`);
});
QUnit.test("other operators", function (assert) {
assert.ok(new Domain([["a", "in", 3]]).contains({ a: 3 }));
assert.ok(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 3 }));
assert.ok(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [3] }));
assert.notOk(new Domain([["a", "in", 3]]).contains({ a: 5 }));
assert.notOk(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 5 }));
assert.notOk(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [5] }));
assert.notOk(new Domain([["a", "not in", 3]]).contains({ a: 3 }));
assert.notOk(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 3 }));
assert.notOk(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [3] }));
assert.ok(new Domain([["a", "not in", 3]]).contains({ a: 5 }));
assert.ok(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 5 }));
assert.ok(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [5] }));
assert.ok(new Domain([["a", "like", "abc"]]).contains({ a: "abc" }));
assert.notOk(new Domain([["a", "like", "abc"]]).contains({ a: "def" }));
assert.ok(new Domain([["a", "=like", "abc"]]).contains({ a: "abc" }));
assert.notOk(new Domain([["a", "=like", "abc"]]).contains({ a: "def" }));
assert.ok(new Domain([["a", "ilike", "abc"]]).contains({ a: "abc" }));
assert.notOk(new Domain([["a", "ilike", "abc"]]).contains({ a: "def" }));
assert.ok(new Domain([["a", "=ilike", "abc"]]).contains({ a: "abc" }));
assert.notOk(new Domain([["a", "=ilike", "abc"]]).contains({ a: "def" }));
});
QUnit.test("creating a domain with a string expression", function (assert) {
assert.strictEqual(new Domain(`[('a', '>=', 3)]`).toString(), `[("a", ">=", 3)]`);
assert.ok(new Domain(`[('a', '>=', 3)]`).contains({ a: 5 }));
});
QUnit.test("can evaluate a python expression", function (assert) {
assert.deepEqual(new Domain(`[('date', '!=', False)]`).toList(), [["date", "!=", false]]);
assert.deepEqual(new Domain(`[('date', '!=', False)]`).toList(), [["date", "!=", false]]);
assert.deepEqual(
new Domain(`[('date', '!=', 1 + 2)]`).toString(),
`[("date", "!=", 1 + 2)]`
);
assert.deepEqual(new Domain(`[('date', '!=', 1 + 2)]`).toList(), [["date", "!=", 3]]);
assert.ok(new Domain(`[('a', '==', 1 + 2)]`).contains({ a: 3 }));
assert.notOk(new Domain(`[('a', '==', 1 + 2)]`).contains({ a: 2 }));
});
QUnit.test("some expression with date stuff", function (assert) {
patchWithCleanup(PyDate, {
today() {
return new PyDate(2013, 4, 24);
},
});
let domainStr =
"[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]";
assert.deepEqual(new Domain(domainStr).toList(), [["date", ">=", "2013-03-25"]]);
domainStr = "[('date', '>=', context_today() - relativedelta(days=30))]";
const domainList = new Domain(domainStr).toList(); // domain creation using `parseExpr` function since the parameter is a string.
assert.deepEqual(
domainList[0][2],
PyDate.create({ day: 25, month: 3, year: 2013 }),
"The right item in the rule in the domain should be a PyDate object"
);
assert.deepEqual(JSON.stringify(domainList), '[["date",">=","2013-03-25"]]');
const domainList2 = new Domain(domainList).toList(); // domain creation using `toAST` function since the parameter is a list.
assert.deepEqual(
domainList2[0][2],
PyDate.create({ day: 25, month: 3, year: 2013 }),
"The right item in the rule in the domain should be a PyDate object"
);
assert.deepEqual(JSON.stringify(domainList2), '[["date",">=","2013-03-25"]]');
});
QUnit.test("Check that there is no dependency between two domains", function (assert) {
// 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);
assert.strictEqual(domain1.toString(), domain2.toString());
domain2.ast.value.unshift({ type: 1, value: "!" });
assert.notEqual(domain1.toString(), domain2.toString());
});
QUnit.test("TRUE and FALSE Domain", function (assert) {
assert.ok(Domain.TRUE.contains({}));
assert.notOk(Domain.FALSE.contains({}));
assert.ok(Domain.and([Domain.TRUE, new Domain([["a", "=", 3]])]).contains({ a: 3 }));
assert.notOk(Domain.and([Domain.FALSE, new Domain([["a", "=", 3]])]).contains({ a: 3 }));
});
QUnit.test("invalid domains should not succeed", function (assert) {
assert.throws(
() => new Domain(["|", ["hr_presence_state", "=", "absent"]]),
/invalid domain .* \(missing 1 segment/
);
assert.throws(
() =>
new Domain([
"|",
"|",
["hr_presence_state", "=", "absent"],
["attendance_state", "=", "checked_in"],
]),
/invalid domain .* \(missing 1 segment/
);
assert.throws(
() => new Domain(["|", "|", ["hr_presence_state", "=", "absent"]]),
/invalid domain .* \(missing 2 segment\(s\)/
);
assert.throws(
() => new Domain(["&", ["composition_mode", "!=", "mass_post"]]),
/invalid domain .* \(missing 1 segment/
);
assert.throws(() => new Domain(["!"]), /invalid domain .* \(missing 1 segment/);
});
QUnit.test("follow relations", function (assert) {
assert.ok(
new Domain([["partner.city", "ilike", "Bru"]]).contains({
name: "Lucas",
partner: {
city: "Bruxelles",
},
})
);
assert.ok(
new Domain([["partner.city.name", "ilike", "Bru"]]).contains({
name: "Lucas",
partner: {
city: {
name: "Bruxelles",
},
},
})
);
});
QUnit.test("Arrays comparison", (assert) => {
const domain = new Domain(["&", ["a", "==", []], ["b", "!=", []]]);
assert.ok(domain.contains({ a: [] }));
assert.ok(domain.contains({ a: [], b: [4] }));
assert.notOk(domain.contains({ a: [1] }));
assert.notOk(domain.contains({ b: [] }));
});
// ---------------------------------------------------------------------------
// Normalization
// ---------------------------------------------------------------------------
QUnit.module("Normalization");
QUnit.test("return simple (normalized) domains", function (assert) {
const domains = ["[]", `[("a", "=", 1)]`, `["!", ("a", "=", 1)]`];
for (const domain of domains) {
assert.strictEqual(new Domain(domain).toString(), domain);
}
});
QUnit.test("properly add the & in a non normalized domain", function (assert) {
assert.strictEqual(
new Domain(`[("a", "=", 1), ("b", "=", 2)]`).toString(),
`["&", ("a", "=", 1), ("b", "=", 2)]`
);
});
QUnit.test("normalize domain with ! operator", function (assert) {
assert.strictEqual(
new Domain(`["!", ("a", "=", 1), ("b", "=", 2)]`).toString(),
`["&", "!", ("a", "=", 1), ("b", "=", 2)]`
);
});
// ---------------------------------------------------------------------------
// Combining domains
// ---------------------------------------------------------------------------
QUnit.module("Combining domains");
QUnit.test("combining zero domain", function (assert) {
assert.strictEqual(Domain.combine([], "AND").toString(), "[]");
assert.strictEqual(Domain.combine([], "OR").toString(), "[]");
assert.ok(Domain.combine([], "AND").contains({ a: 1, b: 2 }));
});
QUnit.test("combining one domain", function (assert) {
assert.strictEqual(
Domain.combine([`[("a", "=", 1)]`], "AND").toString(),
`[("a", "=", 1)]`
);
assert.strictEqual(
Domain.combine([`[("user_id", "=", uid)]`], "AND").toString(),
`[("user_id", "=", uid)]`
);
assert.strictEqual(Domain.combine([[["a", "=", 1]]], "AND").toString(), `[("a", "=", 1)]`);
assert.strictEqual(
Domain.combine(["[('a', '=', '1'), ('b', '!=', 2)]"], "AND").toString(),
`["&", ("a", "=", "1"), ("b", "!=", 2)]`
);
});
QUnit.test("combining two domains", function (assert) {
assert.strictEqual(
Domain.combine([`[("a", "=", 1)]`, "[]"], "AND").toString(),
`[("a", "=", 1)]`
);
assert.strictEqual(
Domain.combine([`[("a", "=", 1)]`, []], "AND").toString(),
`[("a", "=", 1)]`
);
assert.strictEqual(
Domain.combine([new Domain(`[("a", "=", 1)]`), "[]"], "AND").toString(),
`[("a", "=", 1)]`
);
assert.strictEqual(
Domain.combine([new Domain(`[("a", "=", 1)]`), "[]"], "OR").toString(),
`[("a", "=", 1)]`
);
assert.strictEqual(
Domain.combine([[["a", "=", 1]], "[('uid', '<=', uid)]"], "AND").toString(),
`["&", ("a", "=", 1), ("uid", "<=", uid)]`
);
assert.strictEqual(
Domain.combine([[["a", "=", 1]], "[('b', '<=', 3)]"], "OR").toString(),
`["|", ("a", "=", 1), ("b", "<=", 3)]`
);
assert.strictEqual(
Domain.combine(
["[('a', '=', '1'), ('c', 'in', [4, 5])]", "[('b', '<=', 3)]"],
"OR"
).toString(),
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`
);
assert.strictEqual(
Domain.combine(
[new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"), "[('b', '<=', 3)]"],
"OR"
).toString(),
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`
);
});
QUnit.test("combining three domains", function (assert) {
assert.strictEqual(
Domain.combine(
[
new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"),
[["b", "<=", 3]],
`['!', ('uid', '=', uid)]`,
],
"OR"
).toString(),
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), "|", ("b", "<=", 3), "!", ("uid", "=", uid)]`
);
});
// ---------------------------------------------------------------------------
// OPERATOR AND / OR / NOT
// ---------------------------------------------------------------------------
QUnit.module("Operator and/or/not");
QUnit.test("combining two domains with and/or", function (assert) {
assert.strictEqual(Domain.and([`[("a", "=", 1)]`, "[]"]).toString(), `[("a", "=", 1)]`);
assert.strictEqual(Domain.and([`[("a", "=", 1)]`, []]).toString(), `[("a", "=", 1)]`);
assert.strictEqual(
Domain.and([new Domain(`[("a", "=", 1)]`), "[]"]).toString(),
`[("a", "=", 1)]`
);
assert.strictEqual(
Domain.or([new Domain(`[("a", "=", 1)]`), "[]"]).toString(),
`[("a", "=", 1)]`
);
assert.strictEqual(
Domain.and([[["a", "=", 1]], "[('uid', '<=', uid)]"]).toString(),
`["&", ("a", "=", 1), ("uid", "<=", uid)]`
);
assert.strictEqual(
Domain.or([[["a", "=", 1]], "[('b', '<=', 3)]"]).toString(),
`["|", ("a", "=", 1), ("b", "<=", 3)]`
);
assert.strictEqual(
Domain.or(["[('a', '=', '1'), ('c', 'in', [4, 5])]", "[('b', '<=', 3)]"]).toString(),
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`
);
assert.strictEqual(
Domain.or([
new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"),
"[('b', '<=', 3)]",
]).toString(),
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`
);
});
QUnit.test("apply `NOT` on a Domain", function (assert) {
assert.strictEqual(Domain.not("[('a', '=', 1)]").toString(), `["!", ("a", "=", 1)]`);
assert.strictEqual(
Domain.not('[("uid", "<=", uid)]').toString(),
`["!", ("uid", "<=", uid)]`
);
assert.strictEqual(
Domain.not(new Domain("[('a', '=', 1)]")).toString(),
`["!", ("a", "=", 1)]`
);
assert.strictEqual(
Domain.not(new Domain([["a", "=", 1]])).toString(),
`["!", ("a", "=", 1)]`
);
});
QUnit.test("tuple are supported", (assert) => {
assert.deepEqual(
new Domain(`(("field", "like", "string"), ("field", "like", "strOng"))`).toList(),
["&", ["field", "like", "string"], ["field", "like", "strOng"]]
);
assert.deepEqual(new Domain(`("!",("field", "like", "string"))`).toList(), [
"!",
["field", "like", "string"],
]);
assert.throws(() => new Domain(`(("field", "like", "string"))`), /Invalid domain AST/);
assert.throws(() => new Domain(`("&", "&", "|")`), /Invalid domain AST/);
assert.throws(() => new Domain(`("&", "&", 3)`), /Invalid domain AST/);
});
});

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,166 @@
import { expect, test } from "@odoo/hoot";
import { click, queryOne } 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)";
function getHexcode(selector, pseudoSelector) {
const content = getComputedStyle(queryOne(selector), pseudoSelector).content;
if (content !== "none") {
return "\\" + content.replace(/['"]/g, "").charCodeAt(0).toString(16);
} else {
return content;
}
}
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("can be rendered using the tag prop", async () => {
class Parent extends Component {
static components = { DropdownItem };
static props = [];
static template = xml`<DropdownItem tag="'button'">coucou</DropdownItem>`;
}
await mountWithCleanup(Parent);
expect("button.dropdown-item").toHaveCount(1);
});
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");
});
test.tags("desktop");
test("'active' and 'selected' classes shows a checked icon", 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="'no-check'">Item</DropdownItem>
<DropdownItem class="'selected'">Item</DropdownItem>
<DropdownItem class="'active'">Item</DropdownItem>
</t>
</Dropdown>
`;
}
await mountWithCleanup(Parent);
await click(DROPDOWN_TOGGLE);
await animationFrame();
expect(getHexcode(".o-dropdown-item.no-check", ":before")).toEqual("none");
expect(getHexcode(".o-dropdown-item.selected", ":before")).toEqual("\\f00c");
expect(getHexcode(".o-dropdown-item.active", ":before")).toEqual("\\f00c");
});
test.tags("mobile");
test("'active' and 'selected' classes shows a checked icon (mobile)", 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="'no-check'">Item</DropdownItem>
<DropdownItem class="'selected'">Item</DropdownItem>
<DropdownItem class="'active'">Item</DropdownItem>
</t>
</Dropdown>
`;
}
await mountWithCleanup(Parent);
await click(DROPDOWN_TOGGLE);
await animationFrame();
expect(getHexcode(".o-dropdown-item.no-check", "::after")).toEqual("none");
expect(getHexcode(".o-dropdown-item.selected", "::after")).toEqual("\\f00c");
expect(getHexcode(".o-dropdown-item.active", "::after")).toEqual("\\f00c");
});

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

@ -1,149 +0,0 @@
/** @odoo-module **/
import { notificationService } from "@web/core/notifications/notification_service";
import { registry } from "@web/core/registry";
import { effectService } from "@web/core/effects/effect_service";
import { userService } from "@web/core/user_service";
import { session } from "@web/session";
import { makeTestEnv } from "../../helpers/mock_env";
import { makeFakeLocalizationService } from "../../helpers/mock_services";
import {
click,
getFixture,
mockTimeout,
mount,
nextTick,
patchWithCleanup,
} from "../../helpers/utils";
import { Component, markup, xml } from "@odoo/owl";
const serviceRegistry = registry.category("services");
const mainComponentRegistry = registry.category("main_components");
let target;
class Parent extends Component {
setup() {
this.EffectContainer = mainComponentRegistry.get("EffectContainer");
this.NotificationContainer = mainComponentRegistry.get("NotificationContainer");
}
}
Parent.template = xml`
<div>
<t t-component="EffectContainer.Component" t-props="EffectContainer.props" />
<t t-component="NotificationContainer.Component" t-props="NotificationContainer.props" />
</div>
`;
async function makeParent() {
const env = await makeTestEnv({ serviceRegistry });
const parent = await mount(Parent, target, { env });
return parent;
}
QUnit.module("Effect Service", (hooks) => {
let effectParams;
let execRegisteredTimeouts;
hooks.beforeEach(() => {
effectParams = {
message: markup("<div>Congrats!</div>"),
};
execRegisteredTimeouts = mockTimeout().execRegisteredTimeouts;
patchWithCleanup(session, { show_effect: true }); // enable effects
serviceRegistry.add("user", userService);
serviceRegistry.add("effect", effectService);
serviceRegistry.add("notification", notificationService);
serviceRegistry.add("localization", makeFakeLocalizationService());
target = getFixture();
});
QUnit.test("effect service displays a rainbowman by default", async function (assert) {
const parent = await makeParent();
parent.env.services.effect.add();
await nextTick();
execRegisteredTimeouts();
assert.containsOnce(target, ".o_reward");
assert.strictEqual(target.querySelector(".o_reward").innerText, "Well Done!");
});
QUnit.test("rainbowman effect with show_effect: false", async function (assert) {
patchWithCleanup(session, { show_effect: false });
const parent = await makeParent();
parent.env.services.effect.add();
await nextTick();
execRegisteredTimeouts();
assert.containsNone(target, ".o_reward");
assert.containsOnce(target, ".o_notification");
});
QUnit.test("rendering a rainbowman destroy after animation", async function (assert) {
const parent = await makeParent();
parent.env.services.effect.add(effectParams);
await nextTick();
execRegisteredTimeouts();
assert.containsOnce(target, ".o_reward");
assert.containsOnce(target, ".o_reward_rainbow");
assert.strictEqual(
target.querySelector(".o_reward_msg_content").innerHTML,
"<div>Congrats!</div>"
);
const ev = new AnimationEvent("animationend", { animationName: "reward-fading-reverse" });
target.querySelector(".o_reward").dispatchEvent(ev);
await nextTick();
assert.containsNone(target, ".o_reward");
});
QUnit.test("rendering a rainbowman destroy on click", async function (assert) {
const parent = await makeParent();
parent.env.services.effect.add(effectParams);
await nextTick();
execRegisteredTimeouts();
assert.containsOnce(target, ".o_reward");
assert.containsOnce(target, ".o_reward_rainbow");
await click(target);
assert.containsNone(target, ".o_reward");
});
QUnit.test("rendering a rainbowman with an escaped message", async function (assert) {
const parent = await makeParent();
parent.env.services.effect.add(effectParams);
await nextTick();
execRegisteredTimeouts();
assert.containsOnce(target, ".o_reward");
assert.containsOnce(target, ".o_reward_rainbow");
assert.strictEqual(target.querySelector(".o_reward_msg_content").textContent, "Congrats!");
});
QUnit.test("rendering a rainbowman with a custom component", async function (assert) {
assert.expect(2);
const props = { foo: "bar" };
class Custom extends Component {
setup() {
assert.deepEqual(this.props, props, "should have received these props");
}
}
Custom.template = xml`<div class="custom">foo is <t t-esc="props.foo"/></div>`;
const parent = await makeParent();
parent.env.services.effect.add({ Component: Custom, props });
await nextTick();
execRegisteredTimeouts();
assert.strictEqual(
target.querySelector(".o_reward_msg_content").innerHTML,
`<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-clipboard");
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-clipboard");
});
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

@ -1,284 +0,0 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import {
ClientErrorDialog,
Error504Dialog,
ErrorDialog,
RedirectWarningDialog,
SessionExpiredDialog,
WarningDialog,
} from "@web/core/errors/error_dialogs";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { makeTestEnv } from "../../helpers/mock_env";
import { makeFakeDialogService, makeFakeLocalizationService } from "../../helpers/mock_services";
import { click, getFixture, mount, nextTick, patchWithCleanup } from "../../helpers/utils";
let target;
let env;
const serviceRegistry = registry.category("services");
async function makeDialogTestEnv() {
const env = await makeTestEnv();
env.dialogData = {
isActive: true,
close() {},
};
return env;
}
QUnit.module("Error dialogs", {
async beforeEach() {
target = getFixture();
serviceRegistry.add("ui", uiService);
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("localization", makeFakeLocalizationService());
serviceRegistry.add("dialog", makeFakeDialogService());
},
});
QUnit.test("ErrorDialog with traceback", async (assert) => {
assert.expect(11);
assert.containsNone(target, ".o_dialog");
env = await makeDialogTestEnv();
await mount(ErrorDialog, target, {
env,
props: {
message: "Something bad happened",
data: { debug: "Some strange unreadable stack" },
name: "ERROR_NAME",
traceback: "This is a tracback string",
close() {},
},
});
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Odoo Error");
const mainButtons = target.querySelectorAll("main button");
assert.deepEqual(
[...mainButtons].map((el) => el.textContent),
["Copy the full error to clipboard", "See details"]
);
assert.deepEqual(
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
[
"An error occurred",
"Please use the copy button to report the error to your support service.",
]
);
assert.containsNone(target, "div.o_error_detail");
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
click(mainButtons[1]);
await nextTick();
assert.deepEqual(
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
[
"An error occurred",
"Please use the copy button to report the error to your support service.",
"Something bad happened",
]
);
assert.deepEqual(
[...target.querySelectorAll("main .clearfix code")].map((el) => el.textContent),
["ERROR_NAME"]
);
assert.containsOnce(target, "div.o_error_detail");
assert.strictEqual(
target.querySelector("div.o_error_detail").textContent,
"This is a tracback string"
);
});
QUnit.test("Client ErrorDialog with traceback", async (assert) => {
assert.expect(11);
assert.containsNone(target, ".o_dialog");
env = await makeDialogTestEnv();
await mount(ClientErrorDialog, target, {
env,
props: {
message: "Something bad happened",
data: { debug: "Some strange unreadable stack" },
name: "ERROR_NAME",
traceback: "This is a traceback string",
close() {},
},
});
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(
target.querySelector("header .modal-title").textContent,
"Odoo Client Error"
);
const mainButtons = target.querySelectorAll("main button");
assert.deepEqual(
[...mainButtons].map((el) => el.textContent),
["Copy the full error to clipboard", "See details"]
);
assert.deepEqual(
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
[
"An error occurred",
"Please use the copy button to report the error to your support service.",
]
);
assert.containsNone(target, "div.o_error_detail");
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
click(mainButtons[1]);
await nextTick();
assert.deepEqual(
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
[
"An error occurred",
"Please use the copy button to report the error to your support service.",
"Something bad happened",
]
);
assert.deepEqual(
[...target.querySelectorAll("main .clearfix code")].map((el) => el.textContent),
["ERROR_NAME"]
);
assert.containsOnce(target, "div.o_error_detail");
assert.strictEqual(
target.querySelector("div.o_error_detail").textContent,
"This is a traceback string"
);
});
QUnit.test("button clipboard copy error traceback", async (assert) => {
assert.expect(1);
const error = new Error();
error.name = "ERROR_NAME";
error.message = "This is the message";
error.traceback = "This is a traceback";
patchWithCleanup(browser, {
navigator: {
clipboard: {
writeText: (value) => {
assert.strictEqual(
value,
`${error.name}\n${error.message}\n${error.traceback}`
);
},
},
},
});
env = await makeDialogTestEnv();
await mount(ErrorDialog, target, {
env,
props: {
message: error.message,
name: "ERROR_NAME",
traceback: "This is a traceback",
close() {},
},
});
const clipboardButton = target.querySelector(".fa-clipboard");
click(clipboardButton);
await nextTick();
});
QUnit.test("WarningDialog", async (assert) => {
assert.expect(6);
assert.containsNone(target, ".o_dialog");
env = await makeDialogTestEnv();
await mount(WarningDialog, target, {
env,
props: {
exceptionName: "odoo.exceptions.UserError",
message: "...",
data: { arguments: ["Some strange unreadable message"] },
close() {},
},
});
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "User Error");
assert.containsOnce(target, "main .o_dialog_warning");
assert.strictEqual(target.querySelector("main").textContent, "Some strange unreadable message");
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
});
QUnit.test("RedirectWarningDialog", async (assert) => {
assert.expect(10);
const faceActionService = {
name: "action",
start() {
return {
doAction(actionId) {
assert.step(actionId);
},
};
},
};
serviceRegistry.add("action", faceActionService);
env = await makeDialogTestEnv();
assert.containsNone(target, ".o_dialog");
await mount(RedirectWarningDialog, target, {
env,
props: {
data: {
arguments: [
"Some strange unreadable message",
"buy_action_id",
"Buy book on cryptography",
],
},
close() {
assert.step("dialog-closed");
},
},
});
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Odoo Warning");
assert.strictEqual(target.querySelector("main").textContent, "Some strange unreadable message");
const footerButtons = target.querySelectorAll("footer button");
assert.deepEqual(
[...footerButtons].map((el) => el.textContent),
["Buy book on cryptography", "Cancel"]
);
await click(footerButtons[0]); // click on "Buy book on cryptography"
assert.verifySteps(["buy_action_id", "dialog-closed"]);
await click(footerButtons[1]); // click on "Cancel"
assert.verifySteps(["dialog-closed"]);
});
QUnit.test("Error504Dialog", async (assert) => {
assert.expect(5);
assert.containsNone(target, ".o_dialog");
env = await makeDialogTestEnv();
await mount(Error504Dialog, target, { env, props: { close() {} } });
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Request timeout");
assert.strictEqual(
target.querySelector("main p").textContent,
" The operation was interrupted. This usually means that the current operation is taking too much time. "
);
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
});
QUnit.test("SessionExpiredDialog", async (assert) => {
assert.expect(7);
patchWithCleanup(browser, {
location: {
reload() {
assert.step("location reload");
},
},
});
env = await makeDialogTestEnv();
assert.containsNone(target, ".o_dialog");
await mount(SessionExpiredDialog, target, { env, props: { close() {} } });
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(
target.querySelector("header .modal-title").textContent,
"Odoo Session Expired"
);
assert.strictEqual(
target.querySelector("main p").textContent,
" Your Odoo session expired. The current page is about to be refreshed. "
);
const footerButton = target.querySelector(".o_dialog footer button");
assert.strictEqual(footerButton.textContent, "Ok");
click(footerButton);
assert.verifySteps(["location reload"]);
});

View file

@ -0,0 +1,523 @@
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.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,
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.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,
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.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",
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"]);
});
});

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