mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 10:12:04 +02:00
vanilla 18.0
This commit is contained in:
parent
5454004ff9
commit
d7f6d2725e
979 changed files with 428093 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import { after, destroy, getFixture } from "@odoo/hoot";
|
||||
import { queryFirst, queryOne } from "@odoo/hoot-dom";
|
||||
import { App, Component, xml } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { getPopoverForTarget } from "@web/core/popover/popover";
|
||||
import { getTemplate as getTemplateFn } from "@web/core/templates";
|
||||
import { isIterable } from "@web/core/utils/arrays";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { getMockEnv, makeMockEnv } from "./env_test_helpers";
|
||||
|
||||
/**
|
||||
* @typedef {import("@odoo/hoot-dom").Target} Target
|
||||
* @typedef {import("@odoo/owl").Component} Component
|
||||
* @typedef {import("@web/env").OdooEnv} OdooEnv
|
||||
*
|
||||
* @typedef {ConstructorParameters<typeof App>[1]} AppConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template [P=any]
|
||||
* @template [E=any]
|
||||
* @typedef {import("@odoo/owl").ComponentConstructor<P, E>} ComponentConstructor
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {ComponentConstructor} ComponentClass
|
||||
* @param {HTMLElement | ShadowRoot} targetEl
|
||||
* @param {AppConfig} config
|
||||
*/
|
||||
const mountComponentWithCleanup = (ComponentClass, targetEl, config) => {
|
||||
const app = new App(ComponentClass, config);
|
||||
after(() => destroy(app));
|
||||
return app.mount(targetEl);
|
||||
};
|
||||
|
||||
patch(MainComponentsContainer.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
hasMainComponent = true;
|
||||
after(() => (hasMainComponent = false));
|
||||
},
|
||||
});
|
||||
|
||||
let hasMainComponent = false;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {App | Component} parent
|
||||
* @param {(component: Component) => boolean} predicate
|
||||
* @returns {Component | null}
|
||||
*/
|
||||
export function findComponent(parent, predicate) {
|
||||
const rootNode = parent instanceof App ? parent.root : parent.__owl__;
|
||||
const queue = [rootNode, ...Object.values(rootNode.children)];
|
||||
while (queue.length) {
|
||||
const { children, component } = queue.pop();
|
||||
if (predicate(component)) {
|
||||
return component;
|
||||
}
|
||||
queue.unshift(...Object.values(children));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dropdown menu for a specific toggler.
|
||||
*
|
||||
* @param {Target} togglerSelector
|
||||
* @returns {HTMLElement | undefined}
|
||||
*/
|
||||
export function getDropdownMenu(togglerSelector) {
|
||||
let el = queryFirst(togglerSelector);
|
||||
if (el && !el.classList.contains("o-dropdown")) {
|
||||
el = el.querySelector(".o-dropdown");
|
||||
}
|
||||
if (!el) {
|
||||
throw new Error(`getDropdownMenu: Could not find element "${togglerSelector}".`);
|
||||
}
|
||||
return getPopoverForTarget(el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts a given component to the test fixture.
|
||||
*
|
||||
* By default, a `MainComponentsContainer` component is also mounted to the
|
||||
* fixture if none is found in the component tree (this can be overridden by the
|
||||
* `noMainContainer` option).
|
||||
*
|
||||
* @template {ComponentConstructor<P, E>} C
|
||||
* @template [P={}]
|
||||
* @template [E=OdooEnv]
|
||||
* @param {C | string} ComponentClass
|
||||
* @param {AppConfig & {
|
||||
* componentEnv?: Partial<OdooEnv>;
|
||||
* containerEnv?: Partial<OdooEnv>;
|
||||
* fixtureClassName?: string | string[] | null;
|
||||
* env?: E;
|
||||
* noMainContainer?: boolean;
|
||||
* props?: P;
|
||||
* target?: Target;
|
||||
* }} [options]
|
||||
*/
|
||||
export async function mountWithCleanup(ComponentClass, options) {
|
||||
const {
|
||||
componentEnv,
|
||||
containerEnv,
|
||||
customDirectives,
|
||||
env,
|
||||
fixtureClassName = "o_web_client",
|
||||
getTemplate = getTemplateFn,
|
||||
globalValues,
|
||||
noMainContainer,
|
||||
props,
|
||||
target,
|
||||
templates,
|
||||
translatableAttributes,
|
||||
translateFn = _t,
|
||||
} = options || {};
|
||||
|
||||
// Common component configuration
|
||||
const commonConfig = {
|
||||
customDirectives,
|
||||
getTemplate,
|
||||
globalValues,
|
||||
templates,
|
||||
translatableAttributes,
|
||||
translateFn,
|
||||
// The following keys are forced to ensure validation of all tested components
|
||||
dev: false,
|
||||
test: true,
|
||||
warnIfNoStaticProps: true,
|
||||
};
|
||||
|
||||
// Fixture
|
||||
const fixture = getFixture();
|
||||
const targetEl = target ? queryOne(target) : fixture;
|
||||
if (fixtureClassName) {
|
||||
const list = isIterable(fixtureClassName) ? fixtureClassName : [fixtureClassName];
|
||||
fixture.classList.add(...list);
|
||||
}
|
||||
|
||||
if (typeof ComponentClass === "string") {
|
||||
// Convert templates to components (if needed)
|
||||
ComponentClass = class extends Component {
|
||||
static name = "anonymous component";
|
||||
static props = {};
|
||||
static template = xml`${ComponentClass}`;
|
||||
};
|
||||
}
|
||||
|
||||
const commonEnv = env || getMockEnv() || (await makeMockEnv());
|
||||
const componentConfig = {
|
||||
...commonConfig,
|
||||
env: Object.assign(Object.create(commonEnv), componentEnv),
|
||||
name: `TEST: ${ComponentClass.name}`,
|
||||
props,
|
||||
};
|
||||
|
||||
/** @type {InstanceType<C>} */
|
||||
const component = await mountComponentWithCleanup(ComponentClass, targetEl, componentConfig);
|
||||
|
||||
if (!noMainContainer && !hasMainComponent) {
|
||||
const containerConfig = {
|
||||
...commonConfig,
|
||||
env: Object.assign(Object.create(commonEnv), containerEnv),
|
||||
name: `TEST: ${ComponentClass.name} (main container)`,
|
||||
props: {},
|
||||
};
|
||||
await mountComponentWithCleanup(MainComponentsContainer, targetEl, containerConfig);
|
||||
}
|
||||
|
||||
return component;
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
import { after, afterEach } from "@odoo/hoot";
|
||||
import {
|
||||
check,
|
||||
clear,
|
||||
click,
|
||||
dblclick,
|
||||
drag,
|
||||
edit,
|
||||
fill,
|
||||
getActiveElement,
|
||||
hover,
|
||||
keyDown,
|
||||
keyUp,
|
||||
manuallyDispatchProgrammaticEvent,
|
||||
pointerDown,
|
||||
press,
|
||||
queryOne,
|
||||
scroll,
|
||||
select,
|
||||
uncheck,
|
||||
waitFor,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { advanceFrame, advanceTime, animationFrame } from "@odoo/hoot-mock";
|
||||
import { hasTouch } from "@web/core/browser/feature_detection";
|
||||
|
||||
/**
|
||||
* @typedef {import("@odoo/hoot-dom").DragHelpers} DragHelpers
|
||||
* @typedef {import("@odoo/hoot-dom").DragOptions} DragOptions
|
||||
* @typedef {import("@odoo/hoot-dom").FillOptions} FillOptions
|
||||
* @typedef {import("@odoo/hoot-dom").InputValue} InputValue
|
||||
* @typedef {import("@odoo/hoot-dom").KeyStrokes} KeyStrokes
|
||||
* @typedef {import("@odoo/hoot-dom").PointerOptions} PointerOptions
|
||||
* @typedef {import("@odoo/hoot-dom").Position} Position
|
||||
* @typedef {import("@odoo/hoot-dom").QueryOptions} QueryOptions
|
||||
* @typedef {import("@odoo/hoot-dom").Target} Target
|
||||
*
|
||||
* @typedef {DragOptions & {
|
||||
* initialPointerMoveDistance?: number;
|
||||
* pointerDownDuration: number;
|
||||
* }} DragAndDropOptions
|
||||
*
|
||||
* @typedef {{
|
||||
* altKey?: boolean;
|
||||
* ctrlKey?: boolean;
|
||||
* metaKey?: boolean;
|
||||
* shiftKey?: boolean;
|
||||
* }} KeyModifierOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {import("@odoo/hoot-dom").MaybePromise<T>} MaybePromise
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {(...args: Parameters<T>) => MaybePromise<ReturnType<T>>} Promisify
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {typeof click} clickFn
|
||||
* @param {Promise<Element>} nodePromise
|
||||
* @param {PointerOptions & KeyModifierOptions} [options]
|
||||
*/
|
||||
const callClick = async (clickFn, nodePromise, options) => {
|
||||
const actions = [() => clickFn(nodePromise, options)];
|
||||
if (options?.altKey) {
|
||||
actions.unshift(() => keyDown("Alt"));
|
||||
actions.push(() => keyUp("Alt"));
|
||||
}
|
||||
if (options?.ctrlKey) {
|
||||
actions.unshift(() => keyDown("Control"));
|
||||
actions.push(() => keyUp("Control"));
|
||||
}
|
||||
if (options?.metaKey) {
|
||||
actions.unshift(() => keyDown("Meta"));
|
||||
actions.push(() => keyUp("Meta"));
|
||||
}
|
||||
if (options?.shiftKey) {
|
||||
actions.unshift(() => keyDown("Shift"));
|
||||
actions.push(() => keyUp("Shift"));
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
await action();
|
||||
}
|
||||
await animationFrame();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Node} node
|
||||
* @param {number} [distance]
|
||||
*/
|
||||
const dragForTolerance = async (node, distance) => {
|
||||
if (distance === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = {
|
||||
x: distance || 100,
|
||||
y: distance || 100,
|
||||
};
|
||||
await hover(node, { position, relative: true });
|
||||
await advanceFrame();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {number} [delay]
|
||||
* These params are used to move the pointer from an arbitrary distance in the
|
||||
* element to trigger a drag sequence (the distance required to trigger a drag
|
||||
* is defined by the `tolerance` option in the draggable hook builder).
|
||||
* @see {draggable_hook_builder.js}
|
||||
*/
|
||||
const waitForTouchDelay = async (delay) => {
|
||||
if (hasTouch()) {
|
||||
await advanceTime(delay || 500);
|
||||
}
|
||||
};
|
||||
|
||||
let unconsumedContains = [];
|
||||
|
||||
afterEach(() => {
|
||||
if (unconsumedContains.length) {
|
||||
const targets = unconsumedContains.map(String).join(", ");
|
||||
unconsumedContains = [];
|
||||
throw new Error(
|
||||
`called 'contains' on "${targets}" without any action: use 'waitFor' if no interaction is intended`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Target} target
|
||||
* @param {QueryOptions} [options]
|
||||
*/
|
||||
export function contains(target, options) {
|
||||
const consumeContains = () => {
|
||||
if (!consumed) {
|
||||
consumed = true;
|
||||
unconsumedContains.pop();
|
||||
}
|
||||
};
|
||||
|
||||
const focusCurrent = async () => {
|
||||
const node = await nodePromise;
|
||||
if (node !== getActiveElement(node)) {
|
||||
await pointerDown(node);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
let consumed = false;
|
||||
unconsumedContains.push(target);
|
||||
|
||||
/** @type {Promise<Element>} */
|
||||
const nodePromise = waitFor.as("contains")(target, { visible: true, ...options });
|
||||
return {
|
||||
/**
|
||||
* @param {PointerOptions} [options]
|
||||
*/
|
||||
check: async (options) => {
|
||||
consumeContains();
|
||||
await check(nodePromise, options);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {FillOptions} [options]
|
||||
*/
|
||||
clear: async (options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await clear({ confirm: "auto", ...options });
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {PointerOptions & KeyModifierOptions} [options]
|
||||
*/
|
||||
click: async (options) => {
|
||||
consumeContains();
|
||||
await callClick(click, nodePromise, options);
|
||||
},
|
||||
/**
|
||||
* @param {PointerOptions & KeyModifierOptions} [options]
|
||||
*/
|
||||
dblclick: async (options) => {
|
||||
consumeContains();
|
||||
await callClick(dblclick, nodePromise, options);
|
||||
},
|
||||
/**
|
||||
* @param {DragAndDropOptions} [options]
|
||||
* @returns {Promise<DragHelpers>}
|
||||
*/
|
||||
drag: async (options) => {
|
||||
consumeContains();
|
||||
/** @type {typeof cancel} */
|
||||
const cancelWithDelay = async (options) => {
|
||||
await cancel(options);
|
||||
await advanceFrame();
|
||||
};
|
||||
|
||||
/** @type {typeof drop} */
|
||||
const dropWithDelay = async (to, options) => {
|
||||
if (to) {
|
||||
await moveToWithDelay(to, options);
|
||||
}
|
||||
await drop();
|
||||
await advanceFrame();
|
||||
};
|
||||
|
||||
/** @type {typeof moveTo} */
|
||||
const moveToWithDelay = async (to, options) => {
|
||||
await moveTo(to, options);
|
||||
await advanceFrame();
|
||||
|
||||
return helpersWithDelay;
|
||||
};
|
||||
|
||||
const { cancel, drop, moveTo } = await drag(nodePromise, options);
|
||||
const helpersWithDelay = {
|
||||
cancel: cancelWithDelay,
|
||||
drop: dropWithDelay,
|
||||
moveTo: moveToWithDelay,
|
||||
};
|
||||
|
||||
await waitForTouchDelay(options?.pointerDownDuration);
|
||||
|
||||
await dragForTolerance(nodePromise, options?.initialPointerMoveDistance);
|
||||
|
||||
return helpersWithDelay;
|
||||
},
|
||||
/**
|
||||
* @param {Target} target
|
||||
* @param {DragAndDropOptions} [dropOptions]
|
||||
* @param {DragOptions} [dragOptions]
|
||||
*/
|
||||
dragAndDrop: async (target, dropOptions, dragOptions) => {
|
||||
consumeContains();
|
||||
const [from, to] = await Promise.all([nodePromise, waitFor(target)]);
|
||||
const { drop, moveTo } = await drag(from, dragOptions);
|
||||
|
||||
await waitForTouchDelay(dropOptions?.pointerDownDuration);
|
||||
|
||||
await dragForTolerance(from, dropOptions?.initialPointerMoveDistance);
|
||||
|
||||
await moveTo(to, dropOptions);
|
||||
await advanceFrame();
|
||||
|
||||
await drop();
|
||||
await advanceFrame();
|
||||
},
|
||||
/**
|
||||
* @param {InputValue} value
|
||||
* @param {FillOptions} [options]
|
||||
*/
|
||||
edit: async (value, options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await edit(value, { confirm: "auto", ...options });
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {InputValue} value
|
||||
* @param {FillOptions} [options]
|
||||
*/
|
||||
fill: async (value, options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await fill(value, { confirm: "auto", ...options });
|
||||
await animationFrame();
|
||||
},
|
||||
focus: async () => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await animationFrame();
|
||||
},
|
||||
hover: async () => {
|
||||
consumeContains();
|
||||
await hover(nodePromise);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {KeyStrokes} keyStrokes
|
||||
* @param {KeyboardEventInit} [options]
|
||||
*/
|
||||
keyDown: async (keyStrokes, options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await keyDown(keyStrokes, options);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {KeyStrokes} keyStrokes
|
||||
* @param {KeyboardEventInit} [options]
|
||||
*/
|
||||
keyUp: async (keyStrokes, options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await keyUp(keyStrokes, options);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {KeyStrokes} keyStrokes
|
||||
* @param {KeyboardEventInit} [options]
|
||||
*/
|
||||
press: async (keyStrokes, options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await press(keyStrokes, options);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {Position} position
|
||||
*/
|
||||
scroll: async (position) => {
|
||||
consumeContains();
|
||||
// disable "scrollable" check
|
||||
await scroll(nodePromise, position, { scrollable: false, ...options });
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {InputValue} value
|
||||
*/
|
||||
select: async (value) => {
|
||||
consumeContains();
|
||||
await select(value, { target: nodePromise });
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {InputValue} value
|
||||
*/
|
||||
selectDropdownItem: async (value) => {
|
||||
consumeContains();
|
||||
await callClick(click, queryOne(".dropdown-toggle", { root: await nodePromise }));
|
||||
const item = await waitFor(`.dropdown-item:contains(${value})`);
|
||||
await callClick(click, item);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {PointerOptions} [options]
|
||||
*/
|
||||
uncheck: async (options) => {
|
||||
consumeContains();
|
||||
await uncheck(nodePromise, options);
|
||||
await animationFrame();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} style
|
||||
*/
|
||||
export function defineStyle(style) {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.textContent = style;
|
||||
|
||||
document.head.appendChild(styleEl);
|
||||
after(() => styleEl.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export async function editAce(value) {
|
||||
// Ace editor traps focus on "mousedown" events, which are not triggered in
|
||||
// mobile. To support both environments, a single "mouedown" event is triggered
|
||||
// in this specific case. This should not be reproduced and is only accepted
|
||||
// because the tested behaviour comes from a lib on which we have no control.
|
||||
await manuallyDispatchProgrammaticEvent(queryOne(".ace_editor .ace_content"), "mousedown");
|
||||
|
||||
await contains(".ace_editor textarea", { displayed: true, visible: false }).edit(value, {
|
||||
instantly: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import { after, afterEach, beforeEach, registerDebugInfo } from "@odoo/hoot";
|
||||
import { startRouter } from "@web/core/browser/router";
|
||||
import { createDebugContext } from "@web/core/debug/debug_context";
|
||||
import { translatedTerms, translationLoaded } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { makeEnv, startServices } from "@web/env";
|
||||
import { MockServer, makeMockServer } from "./mock_server/mock_server";
|
||||
|
||||
/**
|
||||
* @typedef {Record<keyof Services, any>} Dependencies
|
||||
*
|
||||
* @typedef {import("@web/env").OdooEnv} OdooEnv
|
||||
*
|
||||
* @typedef {import("@web/core/registry").Registry} Registry
|
||||
*
|
||||
* @typedef {import("services").ServiceFactories} Services
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internals
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* TODO: remove when services do not have side effects anymore
|
||||
* This forsaken block of code ensures that all are properly cleaned up after each
|
||||
* test because they were populated during the starting process of some services.
|
||||
*
|
||||
* @param {Registry} registry
|
||||
*/
|
||||
const registerRegistryForCleanup = (registry) => {
|
||||
const content = Object.entries(registry.content).map(([key, value]) => [key, value.slice()]);
|
||||
registriesContent.set(registry, content);
|
||||
|
||||
for (const subRegistry of Object.values(registry.subRegistries)) {
|
||||
registerRegistryForCleanup(subRegistry);
|
||||
}
|
||||
};
|
||||
|
||||
const registriesContent = new WeakMap();
|
||||
/** @type {OdooEnv | null} */
|
||||
let currentEnv = null;
|
||||
|
||||
// Registers all registries for cleanup in all tests
|
||||
beforeEach(() => registerRegistryForCleanup(registry));
|
||||
afterEach(() => restoreRegistry(registry));
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Empties the given registry.
|
||||
*
|
||||
* @param {Registry} registry
|
||||
*/
|
||||
export function clearRegistry(registry) {
|
||||
registry.content = {};
|
||||
registry.elements = null;
|
||||
registry.entries = null;
|
||||
}
|
||||
|
||||
export function getMockEnv() {
|
||||
return currentEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {keyof Services} T
|
||||
* @param {T} name
|
||||
* @returns {Services[T]}
|
||||
*/
|
||||
export function getService(name) {
|
||||
return currentEnv.services[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a mock environment along with a mock server
|
||||
*
|
||||
* @param {Partial<OdooEnv>} [partialEnv]
|
||||
* @param {{
|
||||
* makeNew?: boolean;
|
||||
* }} [options]
|
||||
*/
|
||||
export async function makeMockEnv(partialEnv, options) {
|
||||
if (currentEnv && !options?.makeNew) {
|
||||
throw new Error(
|
||||
`cannot create mock environment: a mock environment has already been declared`
|
||||
);
|
||||
}
|
||||
|
||||
if (!MockServer.current) {
|
||||
await makeMockServer();
|
||||
}
|
||||
|
||||
const env = makeEnv();
|
||||
Object.assign(env, partialEnv, createDebugContext(env)); // This is needed if the views are in debug mode
|
||||
|
||||
registerDebugInfo("env", env);
|
||||
|
||||
if (!currentEnv) {
|
||||
currentEnv = env;
|
||||
startRouter();
|
||||
after(() => {
|
||||
currentEnv = null;
|
||||
|
||||
// Ideally: should be done in a patch of the localization service, but this
|
||||
// is less intrusive for now.
|
||||
if (translatedTerms[translationLoaded]) {
|
||||
for (const key in translatedTerms) {
|
||||
delete translatedTerms[key];
|
||||
}
|
||||
translatedTerms[translationLoaded] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await startServices(env);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a mock environment for dialog tests
|
||||
*
|
||||
* @param {Partial<OdooEnv>} [partialEnv]
|
||||
* @returns {Promise<OdooEnv>}
|
||||
*/
|
||||
export async function makeDialogMockEnv(partialEnv) {
|
||||
return makeMockEnv({
|
||||
...partialEnv,
|
||||
dialogData: {
|
||||
close: () => {},
|
||||
isActive: true,
|
||||
scrollToOrigin: () => {},
|
||||
...partialEnv?.dialogData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {keyof Services} T
|
||||
* @param {T} name
|
||||
* @param {Partial<Services[T]> |
|
||||
* (env: OdooEnv, dependencies: Dependencies) => Services[T]
|
||||
* } serviceFactory
|
||||
*/
|
||||
export function mockService(name, serviceFactory) {
|
||||
const serviceRegistry = registry.category("services");
|
||||
const originalService = serviceRegistry.get(name, null);
|
||||
serviceRegistry.add(
|
||||
name,
|
||||
{
|
||||
...originalService,
|
||||
start(env, dependencies) {
|
||||
if (typeof serviceFactory === "function") {
|
||||
return serviceFactory(env, dependencies);
|
||||
} else {
|
||||
const service = originalService.start(env, dependencies);
|
||||
if (service instanceof Promise) {
|
||||
service.then((value) => patch(value, serviceFactory));
|
||||
} else {
|
||||
patch(service, serviceFactory);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
},
|
||||
},
|
||||
{ force: true }
|
||||
);
|
||||
|
||||
// Patch already initialized service
|
||||
if (currentEnv?.services?.[name]) {
|
||||
if (typeof serviceFactory === "function") {
|
||||
const dependencies = pick(currentEnv.services, ...(originalService.dependencies || []));
|
||||
currentEnv.services[name] = serviceFactory(currentEnv, dependencies);
|
||||
} else {
|
||||
patch(currentEnv.services[name], serviceFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Registry} registry
|
||||
*/
|
||||
export function restoreRegistry(registry) {
|
||||
if (registriesContent.has(registry)) {
|
||||
clearRegistry(registry);
|
||||
|
||||
registry.content = Object.fromEntries(registriesContent.get(registry));
|
||||
}
|
||||
|
||||
for (const subRegistry of Object.values(registry.subRegistries)) {
|
||||
restoreRegistry(subRegistry);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import {
|
||||
animationFrame,
|
||||
queryAll,
|
||||
queryAllAttributes,
|
||||
queryAllTexts,
|
||||
queryOne,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { contains } from "./dom_test_helpers";
|
||||
import { buildSelector } from "./view_test_helpers";
|
||||
import { getDropdownMenu } from "./component_test_helpers";
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
*/
|
||||
export function clickKanbanLoadMore(columnIndex = 0) {
|
||||
return contains(".o_kanban_load_more button", { root: getKanbanColumn(columnIndex) }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickKanbanRecord(options) {
|
||||
await contains(buildSelector(`.o_kanban_record`, options)).click();
|
||||
}
|
||||
|
||||
export async function createKanbanRecord() {
|
||||
await contains(".o_control_panel_main_buttons button.o-kanban-button-new").click();
|
||||
return animationFrame(); // the kanban quick create is rendered in a second animation frame
|
||||
}
|
||||
|
||||
export function discardKanbanRecord() {
|
||||
return contains(".o_kanban_quick_create .o_kanban_cancel").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export function editKanbanColumnName(value) {
|
||||
return contains(".o_column_quick_create input").edit(value);
|
||||
}
|
||||
|
||||
export function editKanbanRecord() {
|
||||
return contains(".o_kanban_quick_create .o_kanban_edit").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fieldName
|
||||
* @param {string} value
|
||||
*/
|
||||
export function editKanbanRecordQuickCreateInput(fieldName, value) {
|
||||
return contains(`.o_kanban_quick_create .o_field_widget[name=${fieldName}] input`).edit(value, {
|
||||
confirm: "tab",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
* @param {boolean} [ignoreFolded=false]
|
||||
*/
|
||||
export function getKanbanColumn(columnIndex = 0, ignoreFolded = false) {
|
||||
let selector = ".o_kanban_group";
|
||||
if (ignoreFolded) {
|
||||
selector += ":not(.o_column_folded)";
|
||||
}
|
||||
return queryAll(selector).at(columnIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
* @param {boolean} [ignoreFolded=false]
|
||||
*/
|
||||
export function getKanbanColumnDropdownMenu(columnIndex = 0, ignoreFolded = false) {
|
||||
const column = getKanbanColumn(columnIndex, ignoreFolded);
|
||||
return getDropdownMenu(column);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex]
|
||||
*/
|
||||
export function getKanbanColumnTooltips(columnIndex) {
|
||||
queryAllAttributes;
|
||||
const root = columnIndex >= 0 && getKanbanColumn(columnIndex);
|
||||
return queryAllAttributes(".o_column_progress .progress-bar", "data-tooltip", { root });
|
||||
}
|
||||
|
||||
export function getKanbanCounters() {
|
||||
return queryAllTexts(".o_animated_number");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
*/
|
||||
export function getKanbanProgressBars(columnIndex = 0) {
|
||||
const column = getKanbanColumn(columnIndex);
|
||||
return queryAll(".o_column_progress .progress-bar", { root: column });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} options
|
||||
*/
|
||||
export function getKanbanRecord(options) {
|
||||
return queryOne(buildSelector(`.o_kanban_record`, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex]
|
||||
*/
|
||||
export function getKanbanRecordTexts(columnIndex) {
|
||||
const root = columnIndex >= 0 && getKanbanColumn(columnIndex);
|
||||
return queryAllTexts(".o_kanban_record:not(.o_kanban_ghost)", { root });
|
||||
}
|
||||
|
||||
export function quickCreateKanbanColumn() {
|
||||
return contains(".o_column_quick_create > .o_quick_create_folded").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
*/
|
||||
export async function quickCreateKanbanRecord(columnIndex = 0) {
|
||||
await contains(".o_kanban_quick_add", { root: getKanbanColumn(columnIndex) }).click();
|
||||
return animationFrame(); // the kanban quick create is rendered in a second animation frame
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
*/
|
||||
export async function toggleKanbanColumnActions(columnIndex = 0) {
|
||||
const column = getKanbanColumn(columnIndex);
|
||||
await contains(".o_kanban_config .dropdown-toggle", { root: column, visible: false }).click();
|
||||
return (buttonText) => {
|
||||
const menu = getDropdownMenu(column);
|
||||
return contains(`.dropdown-item:contains(/\\b${buttonText}\\b/i)`, { root: menu }).click();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [recordIndex=0]
|
||||
*/
|
||||
export function toggleKanbanRecordDropdown(recordIndex = 0) {
|
||||
return contains(`.o_kanban_record:eq(${recordIndex}) .o_dropdown_kanban .dropdown-toggle`, {
|
||||
visible: false,
|
||||
}).click();
|
||||
}
|
||||
|
||||
export function validateKanbanColumn() {
|
||||
return contains(".o_column_quick_create .o_kanban_add").click();
|
||||
}
|
||||
|
||||
export function validateKanbanRecord() {
|
||||
return contains(".o_kanban_quick_create .o_kanban_add").click();
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
|
@ -0,0 +1,11 @@
|
|||
declare module "mock_models" {
|
||||
import { webModels } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export interface IrModelFields extends webModels.IrModelFields {}
|
||||
export interface ResGroups extends webModels.ResGroups {}
|
||||
|
||||
export interface Models {
|
||||
"ir.model.fields": IrModelFields;
|
||||
"res.groups": ResGroups;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class IrAttachment extends ServerModel {
|
||||
_name = "ir.attachment";
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Model } from "../mock_model";
|
||||
|
||||
export class IrModelAccess extends Model {
|
||||
_name = "ir.model.access";
|
||||
|
||||
has_access() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class IrModelFields extends ServerModel {
|
||||
_name = "ir.model.fields";
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Model } from "../mock_model";
|
||||
|
||||
export class IrRule extends Model {
|
||||
_name = "ir.rule";
|
||||
|
||||
has_access() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class IrUiView extends ServerModel {
|
||||
_name = "ir.ui.view";
|
||||
|
||||
has_access() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class ResCountry extends ServerModel {
|
||||
_name = "res.country";
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { serverState } from "../../mock_server_state.hoot";
|
||||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class ResGroups extends ServerModel {
|
||||
_name = "res.groups";
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: serverState.groupId,
|
||||
name: "Internal User",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { serverState } from "../../mock_server_state.hoot";
|
||||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class ResPartner extends ServerModel {
|
||||
_name = "res.partner";
|
||||
|
||||
_records = [
|
||||
...serverState.companies.map((company) => ({
|
||||
id: company.id,
|
||||
active: true,
|
||||
name: company.name,
|
||||
})),
|
||||
{
|
||||
id: serverState.partnerId,
|
||||
active: true,
|
||||
name: serverState.partnerName,
|
||||
},
|
||||
{
|
||||
id: serverState.publicPartnerId,
|
||||
active: true,
|
||||
is_public: true,
|
||||
name: serverState.publicPartnerName,
|
||||
},
|
||||
{
|
||||
id: serverState.odoobotId,
|
||||
active: false,
|
||||
im_status: "bot",
|
||||
name: "OdooBot",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { serverState } from "../../mock_server_state.hoot";
|
||||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class ResUsers extends ServerModel {
|
||||
_name = "res.users";
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: serverState.userId,
|
||||
active: true,
|
||||
company_id: serverState.companies[0]?.id,
|
||||
company_ids: serverState.companies.map((company) => company.id),
|
||||
login: "admin",
|
||||
partner_id: serverState.partnerId,
|
||||
password: "admin",
|
||||
},
|
||||
{
|
||||
id: serverState.publicUserId,
|
||||
active: false,
|
||||
login: "public",
|
||||
partner_id: serverState.publicPartnerId,
|
||||
password: "public",
|
||||
},
|
||||
];
|
||||
|
||||
has_group() {
|
||||
return true;
|
||||
}
|
||||
|
||||
_is_public(id) {
|
||||
return id === serverState.publicUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @type {ServerModel["create"]}
|
||||
*/
|
||||
create() {
|
||||
const userId = super.create(...arguments);
|
||||
const [user] = this.env["res.users"].browse(userId);
|
||||
if (user && !user.partner_id) {
|
||||
this.env["res.users"].write(userId, { partner_id: this.env["res.partner"].create({}) });
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,126 @@
|
|||
import { makeErrorFromResponse } from "@web/core/network/rpc";
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {import("./mock_server").KwArgs<T>} KwArgs
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* This is a flag on keyword arguments, so that they can easily be distinguished
|
||||
* from args in ORM methods. They can then be easily retrieved with {@link getKwArgs}.
|
||||
*/
|
||||
const KWARGS_SYMBOL = Symbol("is_kwargs");
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Flags keyword arguments, so that they can easily be distinguished from regular
|
||||
* arguments in ORM methods.
|
||||
*
|
||||
* They can then be easily retrieved with {@link getKwArgs}.
|
||||
*
|
||||
* @template T
|
||||
* @param {T} kwargs
|
||||
* @returns {T}
|
||||
*/
|
||||
export function makeKwArgs(kwargs) {
|
||||
kwargs[KWARGS_SYMBOL] = true;
|
||||
return kwargs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves keyword arguments flagged by {@link makeKwArgs} from an arguments list.
|
||||
*
|
||||
* @template {string} T
|
||||
* @param {Iterable<any>} allArgs arguments of method
|
||||
* @param {...T} argNames ordered names of positional arguments
|
||||
* @returns {KwArgs<Record<T, any>>} kwargs normalized params
|
||||
*/
|
||||
export function getKwArgs(allArgs, ...argNames) {
|
||||
const args = [...allArgs];
|
||||
const kwargs = args.at(-1)?.[KWARGS_SYMBOL] ? args.pop() : makeKwArgs({});
|
||||
if (args.length > argNames.length) {
|
||||
throw new MockServerError("more positional arguments than there are given argument names");
|
||||
}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== null && args[i] !== undefined) {
|
||||
kwargs[argNames[i]] = args[i];
|
||||
}
|
||||
}
|
||||
return kwargs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./mock_model").ModelRecord} record
|
||||
*/
|
||||
export function getRecordQualifier(record) {
|
||||
if (record.id) {
|
||||
return `record #${record.id}`;
|
||||
}
|
||||
const name = record.display_name || record.name;
|
||||
if (name) {
|
||||
return `record named "${name}"`;
|
||||
}
|
||||
return "anonymous record";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | any>} params
|
||||
*/
|
||||
export function makeServerError({
|
||||
code,
|
||||
context,
|
||||
description,
|
||||
message,
|
||||
subType,
|
||||
errorName,
|
||||
type,
|
||||
args,
|
||||
} = {}) {
|
||||
return makeErrorFromResponse({
|
||||
code: code || 200,
|
||||
message: message || "Odoo Server Error",
|
||||
data: {
|
||||
name: errorName || `odoo.exceptions.${type || "UserError"}`,
|
||||
debug: "traceback",
|
||||
arguments: args || [],
|
||||
context: context || {},
|
||||
subType,
|
||||
message: description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} [separator=","]
|
||||
*/
|
||||
export function safeSplit(value, separator) {
|
||||
return value
|
||||
? String(value)
|
||||
.trim()
|
||||
.split(separator || ",")
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the flag for keyword arguments.
|
||||
*
|
||||
* @template T
|
||||
* @param {T} kwargs
|
||||
* @returns {T}
|
||||
*/
|
||||
export function unmakeKwArgs(kwargs) {
|
||||
delete kwargs[KWARGS_SYMBOL];
|
||||
return kwargs;
|
||||
}
|
||||
|
||||
export class MockServerError extends Error {
|
||||
name = "MockServerError";
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
|
||||
|
||||
import { after, before, beforeEach, createJobScopedGetter } from "@odoo/hoot";
|
||||
import { validateType } from "@odoo/owl";
|
||||
|
||||
const { view_info } = odoo.__session_info__ || {};
|
||||
delete odoo.__session_info__;
|
||||
|
||||
const { Settings } = luxon;
|
||||
|
||||
/**
|
||||
* @typedef {typeof SERVER_STATE_VALUES} ServerState
|
||||
*/
|
||||
|
||||
const applyDefaults = () => {
|
||||
Object.assign(Settings, DEFAULT_LUXON_SETTINGS);
|
||||
|
||||
notifySubscribers();
|
||||
};
|
||||
|
||||
const notifySubscribers = () => {
|
||||
// Apply new state to all subscribers
|
||||
for (const [target, callback] of subscriptions) {
|
||||
const descriptors = Object.getOwnPropertyDescriptors(callback(serverState));
|
||||
Object.defineProperties(target, descriptors);
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_LUXON_SETTINGS = {
|
||||
defaultLocale: Settings.defaultLocale,
|
||||
defaultNumberingSystem: Settings.defaultNumberingSystem,
|
||||
defaultOutputCalendar: Settings.defaultOutputCalendar,
|
||||
defaultZone: Settings.defaultZone,
|
||||
defaultWeekSettings: Settings.defaultWeekSettings,
|
||||
};
|
||||
const SERVER_STATE_VALUES = {
|
||||
companies: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Hermit",
|
||||
},
|
||||
],
|
||||
currencies: [
|
||||
{
|
||||
id: 1,
|
||||
name: "USD",
|
||||
position: "before",
|
||||
symbol: "$",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "EUR",
|
||||
position: "after",
|
||||
symbol: "€",
|
||||
},
|
||||
],
|
||||
db: "test",
|
||||
debug: "",
|
||||
groupId: 11,
|
||||
lang: "en",
|
||||
multiLang: false,
|
||||
odoobotId: 418,
|
||||
partnerId: 17,
|
||||
partnerName: "Mitchell Admin",
|
||||
publicPartnerId: 18,
|
||||
publicPartnerName: "Public user",
|
||||
publicUserId: 8,
|
||||
serverVersion: [1, 0, 0, "final", 0, ""],
|
||||
timezone: "taht",
|
||||
userContext: {},
|
||||
userId: 7,
|
||||
view_info,
|
||||
};
|
||||
|
||||
const SERVER_STATE_VALUES_SCHEMA = {
|
||||
companies: { type: Array, element: Object },
|
||||
currencies: { type: Array, element: Object },
|
||||
db: String,
|
||||
debug: String,
|
||||
groupId: [Number, { value: false }],
|
||||
lang: String,
|
||||
multiLang: Boolean,
|
||||
odoobotId: [Number, { value: false }],
|
||||
partnerId: [Number, { value: false }],
|
||||
partnerName: String,
|
||||
publicPartnerId: [Number, { value: false }],
|
||||
publicPartnerName: String,
|
||||
publicUserId: Number,
|
||||
serverVersion: { type: Array, element: [String, Number] },
|
||||
timezone: String,
|
||||
userContext: Object,
|
||||
userId: [Number, { value: false }],
|
||||
view_info: Object,
|
||||
};
|
||||
|
||||
const getServerStateValues = createJobScopedGetter(
|
||||
(previousValues) => ({
|
||||
...JSON.parse(JSON.stringify(SERVER_STATE_VALUES)),
|
||||
...previousValues,
|
||||
}),
|
||||
applyDefaults
|
||||
);
|
||||
|
||||
/** @type {Map<any, (state: ServerState) => any>} */
|
||||
const subscriptions = new Map([
|
||||
[
|
||||
odoo,
|
||||
({ db, debug, serverVersion }) => ({
|
||||
...odoo,
|
||||
debug,
|
||||
info: {
|
||||
db,
|
||||
server_version: serverVersion.slice(0, 2).join("."),
|
||||
server_version_info: serverVersion,
|
||||
isEnterprise: serverVersion.slice(-1)[0] === "e",
|
||||
},
|
||||
isReady: true,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T} target
|
||||
* @param {(state: ServerState) => T} callback
|
||||
*/
|
||||
export function onServerStateChange(target, callback) {
|
||||
before(() => {
|
||||
subscriptions.set(target, callback);
|
||||
});
|
||||
after(() => {
|
||||
subscriptions.delete(target);
|
||||
});
|
||||
}
|
||||
|
||||
export const serverState = new Proxy(SERVER_STATE_VALUES, {
|
||||
deleteProperty(_target, p) {
|
||||
return Reflect.deleteProperty(getServerStateValues(), p);
|
||||
},
|
||||
get(_target, p) {
|
||||
return Reflect.get(getServerStateValues(), p);
|
||||
},
|
||||
has(_target, p) {
|
||||
return Reflect.has(getServerStateValues(), p);
|
||||
},
|
||||
set(_target, p, newValue) {
|
||||
if (p in SERVER_STATE_VALUES_SCHEMA && newValue !== null && newValue !== undefined) {
|
||||
const errorMessage = validateType(p, newValue, SERVER_STATE_VALUES_SCHEMA[p]);
|
||||
if (errorMessage) {
|
||||
throw new TypeError(errorMessage);
|
||||
}
|
||||
}
|
||||
const result = Reflect.set(getServerStateValues(), p, newValue);
|
||||
if (result) {
|
||||
notifySubscribers();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(applyDefaults, { global: true });
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
|
||||
|
||||
import { onServerStateChange, serverState } from "./mock_server_state.hoot";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {typeof serverState} serverState
|
||||
*/
|
||||
const makeSession = ({
|
||||
companies,
|
||||
db,
|
||||
lang,
|
||||
partnerId,
|
||||
partnerName,
|
||||
serverVersion,
|
||||
timezone,
|
||||
userContext,
|
||||
userId,
|
||||
view_info,
|
||||
}) => ({
|
||||
active_ids_limit: 20000,
|
||||
bundle_params: {
|
||||
debug: new URLSearchParams(location.search).get("debug"),
|
||||
lang,
|
||||
},
|
||||
cache_hashes: {
|
||||
load_menus: "164b675eb9bf49f8bca52e350cd81482a8cf0d0c1c8a47d99bd063c0a0bf4f0d",
|
||||
translations: "f17c8e4bb0fd4d5db2615d28713486df97853a8f",
|
||||
},
|
||||
can_insert_in_spreadsheet: true,
|
||||
db,
|
||||
display_switch_company_menu: false,
|
||||
home_action_id: false,
|
||||
is_admin: true,
|
||||
is_internal_user: true,
|
||||
is_system: true,
|
||||
max_file_upload_size: 134217728,
|
||||
name: partnerName,
|
||||
partner_display_name: partnerName,
|
||||
partner_id: partnerId,
|
||||
profile_collectors: null,
|
||||
profile_params: null,
|
||||
profile_session: null,
|
||||
server_version: serverVersion.slice(0, 2).join("."),
|
||||
server_version_info: serverVersion,
|
||||
show_effect: true,
|
||||
uid: userId,
|
||||
// Commit: 3e847fc8f499c96b8f2d072ab19f35e105fd7749
|
||||
// to see what user_companies is
|
||||
user_companies: {
|
||||
allowed_companies: Object.fromEntries(companies.map((company) => [company.id, company])),
|
||||
current_company: companies[0]?.id,
|
||||
disallowed_ancestor_companies: {},
|
||||
},
|
||||
user_context: {
|
||||
...userContext,
|
||||
lang,
|
||||
tz: timezone,
|
||||
uid: userId,
|
||||
},
|
||||
user_id: [userId],
|
||||
username: "admin",
|
||||
["web.base.url"]: "http://localhost:8069",
|
||||
view_info,
|
||||
});
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function mockSessionFactory() {
|
||||
return () => {
|
||||
const session = makeSession(serverState);
|
||||
|
||||
onServerStateChange(session, makeSession);
|
||||
|
||||
return { session };
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,731 @@
|
|||
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
|
||||
|
||||
import { describe, dryRun, globals, start, stop } from "@odoo/hoot";
|
||||
import { Deferred, delay } from "@odoo/hoot-dom";
|
||||
import { watchAddedNodes, watchKeys, watchListeners } from "@odoo/hoot-mock";
|
||||
|
||||
import { mockBrowserFactory } from "./mock_browser.hoot";
|
||||
import { mockCurrencyFactory } from "./mock_currency.hoot";
|
||||
import { mockSessionFactory } from "./mock_session.hoot";
|
||||
import { makeTemplateFactory } from "./mock_templates.hoot";
|
||||
import { mockUserFactory } from "./mock_user.hoot";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* addonsKey: string;
|
||||
* filter?: (path: string) => boolean;
|
||||
* moduleNames: string[];
|
||||
* }} ModuleSet
|
||||
*
|
||||
* @typedef {{
|
||||
* addons?: Iterable<string>;
|
||||
* }} ModuleSetParams
|
||||
*/
|
||||
|
||||
const { fetch: realFetch } = globals;
|
||||
const { define, loader } = odoo;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Record<any, any>} object
|
||||
*/
|
||||
function clearObject(object) {
|
||||
for (const key in object) {
|
||||
delete object[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fileSuffix
|
||||
* @param {string[]} entryPoints
|
||||
* @param {Set<string>} additionalAddons
|
||||
*/
|
||||
async function defineModuleSet(fileSuffix, entryPoints, additionalAddons) {
|
||||
/** @type {ModuleSet} */
|
||||
const moduleSet = {};
|
||||
if (additionalAddons.has("*")) {
|
||||
// Use all addons
|
||||
moduleSet.addonsKey = "*";
|
||||
moduleSet.moduleNames = sortedModuleNames.filter((name) => !name.endsWith(fileSuffix));
|
||||
} else {
|
||||
// Use subset of addons
|
||||
for (const entryPoint of entryPoints) {
|
||||
additionalAddons.add(getAddonName(entryPoint));
|
||||
}
|
||||
const addons = await fetchDependencies(additionalAddons);
|
||||
for (const addon in AUTO_INCLUDED_ADDONS) {
|
||||
if (addons.has(addon)) {
|
||||
for (const toInclude of AUTO_INCLUDED_ADDONS[addon]) {
|
||||
addons.add(toInclude);
|
||||
}
|
||||
}
|
||||
}
|
||||
const filter = (path) => addons.has(getAddonName(path));
|
||||
|
||||
// Module names are cached for each configuration of addons
|
||||
const joinedAddons = [...addons].sort().join(",");
|
||||
if (!moduleNamesCache.has(joinedAddons)) {
|
||||
moduleNamesCache.set(
|
||||
joinedAddons,
|
||||
sortedModuleNames.filter((name) => !name.endsWith(fileSuffix) && filter(name))
|
||||
);
|
||||
}
|
||||
|
||||
moduleSet.addonsKey = joinedAddons;
|
||||
moduleSet.filter = filter;
|
||||
moduleSet.moduleNames = moduleNamesCache.get(joinedAddons);
|
||||
}
|
||||
|
||||
return moduleSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fileSuffix
|
||||
* @param {string[]} entryPoints
|
||||
*/
|
||||
async function describeDrySuite(fileSuffix, entryPoints) {
|
||||
const moduleSet = await defineModuleSet(fileSuffix, entryPoints, new Set(["*"]));
|
||||
const moduleSetLoader = new ModuleSetLoader(moduleSet);
|
||||
|
||||
moduleSetLoader.setup();
|
||||
|
||||
for (const entryPoint of entryPoints) {
|
||||
// Run test factory
|
||||
describe(getSuitePath(entryPoint), () => {
|
||||
// Load entry point module
|
||||
const fullModuleName = entryPoint + fileSuffix;
|
||||
const module = moduleSetLoader.startModule(fullModuleName);
|
||||
|
||||
// Check exports (shouldn't have any)
|
||||
const exports = Object.keys(module || {});
|
||||
if (exports.length) {
|
||||
throw new Error(
|
||||
`Test files cannot have exports, found the following exported member(s) in module ${fullModuleName}:${exports
|
||||
.map((name) => `\n - ${name}`)
|
||||
.join("")}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await moduleSetLoader.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Set<string>} addons
|
||||
*/
|
||||
async function fetchDependencies(addons) {
|
||||
// Fetch missing dependencies
|
||||
const addonsToFetch = [];
|
||||
for (const addon of addons) {
|
||||
if (!dependencyCache[addon] && !DEFAULT_ADDONS.includes(addon)) {
|
||||
addonsToFetch.push(addon);
|
||||
dependencyCache[addon] = new Deferred();
|
||||
}
|
||||
}
|
||||
if (addonsToFetch.length) {
|
||||
if (!dependencyBatch.length) {
|
||||
dependencyBatchPromise = Deferred.resolve().then(() => {
|
||||
const module_names = [...new Set(dependencyBatch)];
|
||||
dependencyBatch = [];
|
||||
return orm("ir.module.module.dependency", "all_dependencies", [], { module_names });
|
||||
});
|
||||
}
|
||||
dependencyBatch.push(...addonsToFetch);
|
||||
dependencyBatchPromise.then((allDependencies) => {
|
||||
for (const [moduleName, dependencyNames] of Object.entries(allDependencies)) {
|
||||
dependencyCache[moduleName] ||= new Deferred();
|
||||
dependencyCache[moduleName].resolve();
|
||||
|
||||
dependencies[moduleName] = dependencyNames.filter(
|
||||
(dep) => !DEFAULT_ADDONS.includes(dep)
|
||||
);
|
||||
}
|
||||
|
||||
resolveAddonDependencies(dependencies);
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([...addons].map((addon) => dependencyCache[addon]));
|
||||
|
||||
return getDependencies(addons);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
function findMockFactory(name) {
|
||||
if (MODULE_MOCKS_BY_NAME.has(name)) {
|
||||
return MODULE_MOCKS_BY_NAME.get(name);
|
||||
}
|
||||
for (const [key, factory] of MODULE_MOCKS_BY_REGEX) {
|
||||
if (key instanceof RegExp && key.test(name)) {
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce the size of the given field and freeze it.
|
||||
*
|
||||
* @param {Record<string, unknown>>} field
|
||||
*/
|
||||
function freezeField(field) {
|
||||
delete field.name;
|
||||
if (field.groupable) {
|
||||
delete field.groupable;
|
||||
}
|
||||
if (!field.readonly && !field.related) {
|
||||
delete field.readonly;
|
||||
}
|
||||
if (!field.required) {
|
||||
delete field.required;
|
||||
}
|
||||
if (field.searchable) {
|
||||
delete field.searchable;
|
||||
}
|
||||
if (field.sortable) {
|
||||
delete field.sortable;
|
||||
}
|
||||
if (field.store && !field.related) {
|
||||
delete field.store;
|
||||
}
|
||||
return Object.freeze(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce the size of the given model and freeze it.
|
||||
*
|
||||
* @param {Record<string, unknown>>} model
|
||||
*/
|
||||
function freezeModel(model) {
|
||||
if (model.fields) {
|
||||
for (const [fieldName, field] of Object.entries(model.fields)) {
|
||||
model.fields[fieldName] = freezeField(field);
|
||||
}
|
||||
Object.freeze(model.fields);
|
||||
}
|
||||
if (model.inherit) {
|
||||
if (model.inherit.length) {
|
||||
model.inherit = model.inherit.filter((m) => m !== "base");
|
||||
}
|
||||
if (!model.inherit.length) {
|
||||
delete model.inherit;
|
||||
}
|
||||
}
|
||||
if (model.order === "id") {
|
||||
delete model.order;
|
||||
}
|
||||
if (model.parent_name === "parent_id") {
|
||||
delete model.parent_name;
|
||||
}
|
||||
if (model.rec_name === "name") {
|
||||
delete model.rec_name;
|
||||
}
|
||||
return Object.freeze(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
function getAddonName(name) {
|
||||
return name.match(R_PATH_ADDON)?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Iterable<string>} addons
|
||||
*/
|
||||
function getDependencies(addons) {
|
||||
const result = new Set(DEFAULT_ADDONS);
|
||||
for (const addon of addons) {
|
||||
if (DEFAULT_ADDONS.includes(addon)) {
|
||||
continue;
|
||||
}
|
||||
result.add(addon);
|
||||
for (const dep of dependencies[addon]) {
|
||||
result.add(dep);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
function getSuitePath(name) {
|
||||
return name.replace("../tests/", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the original definition of a factory.
|
||||
*
|
||||
* @param {string} name
|
||||
*/
|
||||
function makeFixedFactory(name) {
|
||||
return () => {
|
||||
if (!loader.modules.has(name)) {
|
||||
loader.startModule(name);
|
||||
}
|
||||
return loader.modules.get(name);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toned-down version of the RPC + ORM features since this file cannot depend on
|
||||
* them.
|
||||
*
|
||||
* @param {string} model
|
||||
* @param {string} method
|
||||
* @param {any[]} args
|
||||
* @param {Record<string, any>} kwargs
|
||||
*/
|
||||
async function orm(model, method, args, kwargs) {
|
||||
const response = await realFetch(`/web/dataset/call_kw/${model}/${method}`, {
|
||||
body: JSON.stringify({
|
||||
id: nextRpcId++,
|
||||
jsonrpc: "2.0",
|
||||
method: "call",
|
||||
params: { args, kwargs, method, model },
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
const { error, result } = await response.json();
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {Record<string, string[]>} T
|
||||
* @param {T} dependencies
|
||||
*/
|
||||
function resolveAddonDependencies(dependencies) {
|
||||
const findJob = () =>
|
||||
Object.entries(remaining).find(([, deps]) => deps.every((dep) => dep in solved));
|
||||
|
||||
const remaining = { ...dependencies };
|
||||
/** @type {T} */
|
||||
const solved = {};
|
||||
|
||||
let entry;
|
||||
while ((entry = findJob())) {
|
||||
const [name, deps] = entry;
|
||||
solved[name] = [...new Set(deps.flatMap((dep) => [dep, ...solved[dep]]))];
|
||||
delete remaining[name];
|
||||
}
|
||||
|
||||
const remainingKeys = Object.keys(remaining);
|
||||
if (remainingKeys.length) {
|
||||
throw new Error(`Unresolved dependencies: ${remainingKeys.join(", ")}`);
|
||||
}
|
||||
|
||||
Object.assign(dependencies, solved);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>>} model
|
||||
*/
|
||||
function unfreezeModel(model) {
|
||||
const fields = Object.create(null);
|
||||
if (model.fields) {
|
||||
for (const [fieldName, field] of Object.entries(model.fields)) {
|
||||
fields[fieldName] = { ...field };
|
||||
}
|
||||
}
|
||||
return { ...model, fields };
|
||||
}
|
||||
|
||||
/**
|
||||
* This method tries to manually run the garbage collector (if exposed) and logs
|
||||
* the current heap size (if available). It is meant to be called right after a
|
||||
* suite's module set has been fully executed.
|
||||
*
|
||||
* This is used for debugging memory leaks, or if the containing process running
|
||||
* unit tests doesn't know how much available memory it actually has.
|
||||
*
|
||||
* To enable this feature, the containing process must simply use the `--expose-gc`
|
||||
* flag.
|
||||
*
|
||||
* @param {string} label
|
||||
* @param {number} [testCount]
|
||||
*/
|
||||
async function __gcAndLogMemory(label, testCount) {
|
||||
if (typeof window.gc !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleanup last retained textarea
|
||||
const textarea = document.createElement("textarea");
|
||||
document.body.appendChild(textarea);
|
||||
textarea.value = "aaa";
|
||||
textarea.focus();
|
||||
textarea.remove();
|
||||
|
||||
// Run garbage collection
|
||||
await window.gc({ type: "major", execution: "async" });
|
||||
|
||||
// Log memory usage
|
||||
const logs = [
|
||||
`[MEMINFO] ${label} (after GC)`,
|
||||
"- used:",
|
||||
window.performance.memory.usedJSHeapSize,
|
||||
"- total:",
|
||||
window.performance.memory.totalJSHeapSize,
|
||||
"- limit:",
|
||||
window.performance.memory.jsHeapSizeLimit,
|
||||
];
|
||||
if (Number.isInteger(testCount)) {
|
||||
logs.push("- tests:", testCount);
|
||||
}
|
||||
console.log(...logs);
|
||||
}
|
||||
|
||||
/** @extends {OdooModuleLoader} */
|
||||
class ModuleSetLoader extends loader.constructor {
|
||||
cleanups = [];
|
||||
preventGlobalDefine = false;
|
||||
|
||||
/**
|
||||
* @param {ModuleSet} moduleSet
|
||||
*/
|
||||
constructor(moduleSet) {
|
||||
super();
|
||||
|
||||
this.factories = new Map(loader.factories);
|
||||
this.modules = new Map(loader.modules);
|
||||
this.moduleSet = moduleSet;
|
||||
|
||||
odoo.define = this.define.bind(this);
|
||||
odoo.loader = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @type {typeof loader["addJob"]}
|
||||
*/
|
||||
addJob(name) {
|
||||
if (this.canAddModule(name)) {
|
||||
super.addJob(...arguments);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
canAddModule(name) {
|
||||
const { filter } = this.moduleSet;
|
||||
return !filter || filter(name) || R_DEFAULT_MODULE.test(name);
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
// Wait for side-effects to be properly caught up by the different "watchers"
|
||||
// (like mutation records).
|
||||
await delay();
|
||||
|
||||
odoo.define = define;
|
||||
odoo.loader = loader;
|
||||
|
||||
while (this.cleanups.length) {
|
||||
this.cleanups.pop()();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @type {typeof loader["define"]}
|
||||
*/
|
||||
define(name, deps, factory) {
|
||||
if (!this.preventGlobalDefine && !loader.factories.has(name)) {
|
||||
// Lazy-loaded modules are added to the main loader for next ModuleSetLoader
|
||||
// instances.
|
||||
loader.define(name, deps, factory, true);
|
||||
// We assume that lazy-loaded modules are not required by any other
|
||||
// module.
|
||||
sortedModuleNames.push(name);
|
||||
moduleNamesCache.clear();
|
||||
}
|
||||
return super.define(...arguments);
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.cleanups.push(
|
||||
watchKeys(window.odoo),
|
||||
watchKeys(window, ALLOWED_GLOBAL_KEYS),
|
||||
watchListeners(window),
|
||||
watchAddedNodes(window)
|
||||
);
|
||||
|
||||
// Load module set modules (without entry point)
|
||||
for (const name of this.moduleSet.moduleNames) {
|
||||
const mockFactory = findMockFactory(name);
|
||||
if (mockFactory) {
|
||||
// Use mock
|
||||
this.factories.set(name, {
|
||||
deps: [],
|
||||
fn: mockFactory(name, this.factories.get(name)),
|
||||
});
|
||||
}
|
||||
if (!this.modules.has(name)) {
|
||||
// Run (or re-run) module factory
|
||||
this.startModule(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @type {typeof loader["startModule"]}
|
||||
*/
|
||||
startModule(name) {
|
||||
if (this.canAddModule(name)) {
|
||||
return super.startModule(...arguments);
|
||||
}
|
||||
this.jobs.delete(name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_GLOBAL_KEYS = [
|
||||
"ace", // Ace editor
|
||||
// Bootstrap.js is voluntarily ignored as it is deprecated
|
||||
"Chart", // Chart.js
|
||||
"DiffMatchPatch", // Diff Match Patch
|
||||
"DOMPurify", // DOMPurify
|
||||
"FullCalendar", // Full Calendar
|
||||
"L", // Leaflet
|
||||
"lamejs", // LameJS
|
||||
"luxon", // Luxon
|
||||
"odoo", // Odoo global object
|
||||
"owl", // Owl
|
||||
"pdfjsLib", // PDF JS
|
||||
"Popper", // Popper
|
||||
"Prism", // PrismJS
|
||||
"SignaturePad", // Signature Pad
|
||||
"StackTrace", // StackTrace
|
||||
"ZXing", // ZXing
|
||||
];
|
||||
const AUTO_INCLUDED_ADDONS = {
|
||||
/**
|
||||
* spreadsheet addons defines a module that does not starts with `@spreadsheet` but `@odoo` (`@odoo/o-spreadsheet)
|
||||
* To ensure that this module is loaded, we have to include `odoo` in the dependencies
|
||||
*/
|
||||
spreadsheet: ["odoo"],
|
||||
/**
|
||||
* Add all view types by default
|
||||
*/
|
||||
web_enterprise: ["web_gantt", "web_grid", "web_map"],
|
||||
};
|
||||
const CSRF_TOKEN = odoo.csrf_token;
|
||||
const DEFAULT_ADDONS = ["base", "web"];
|
||||
const MODULE_MOCKS_BY_NAME = new Map([
|
||||
// Fixed modules
|
||||
["@web/core/template_inheritance", makeFixedFactory],
|
||||
// Other mocks
|
||||
["@web/core/browser/browser", mockBrowserFactory],
|
||||
["@web/core/currency", mockCurrencyFactory],
|
||||
["@web/core/templates", makeTemplateFactory],
|
||||
["@web/core/user", mockUserFactory],
|
||||
["@web/session", mockSessionFactory],
|
||||
]);
|
||||
const MODULE_MOCKS_BY_REGEX = new Map([
|
||||
// Fixed modules
|
||||
[/\.bundle\.xml$/, makeFixedFactory],
|
||||
]);
|
||||
const R_DEFAULT_MODULE = /^@odoo\/(owl|hoot)/;
|
||||
const R_PATH_ADDON = /^[@/]?(\w+)/;
|
||||
const TEMPLATE_MODULE_NAME = "@web/core/templates";
|
||||
|
||||
/** @type {Record<string, string[]} */
|
||||
const dependencies = {};
|
||||
/** @type {Record<string, Deferred} */
|
||||
const dependencyCache = {};
|
||||
/** @type {Record<string, Promise<Response>} */
|
||||
const globalFetchCache = Object.create(null);
|
||||
/** @type {Set<string>} */
|
||||
const modelsToFetch = new Set();
|
||||
/** @type {Map<string, string[]>} */
|
||||
const moduleNamesCache = new Map();
|
||||
/** @type {Map<string, Record<string, unknown>>} */
|
||||
const serverModelCache = new Map();
|
||||
/** @type {string[]} */
|
||||
const sortedModuleNames = [];
|
||||
|
||||
/** @type {string[]} */
|
||||
let dependencyBatch = [];
|
||||
/** @type {Promise<Record<string, string[]>> | null} */
|
||||
let dependencyBatchPromise = null;
|
||||
let nextRpcId = 1e9;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function clearServerModelCache() {
|
||||
serverModelCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Iterable<string>} modelNames
|
||||
*/
|
||||
export async function fetchModelDefinitions(modelNames) {
|
||||
// Fetch missing definitions
|
||||
const namesList = [...modelsToFetch];
|
||||
if (namesList.length) {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", CSRF_TOKEN);
|
||||
formData.set("model_names", JSON.stringify(namesList));
|
||||
|
||||
const response = await realFetch("/web/model/get_definitions", {
|
||||
body: formData,
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const [s, some, does] =
|
||||
namesList.length === 1 ? ["", "this", "does"] : ["s", "some or all of these", "do"];
|
||||
const message = `Could not fetch definition${s} for server model${s} "${namesList.join(
|
||||
`", "`
|
||||
)}": ${some} model${s} ${does} not exist`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const modelDefs = await response.json();
|
||||
|
||||
for (const [modelName, modelDef] of Object.entries(modelDefs)) {
|
||||
serverModelCache.set(modelName, freezeModel(modelDef));
|
||||
modelsToFetch.delete(modelName);
|
||||
}
|
||||
}
|
||||
|
||||
const result = Object.create(null);
|
||||
for (const modelName of modelNames) {
|
||||
result[modelName] = unfreezeModel(serverModelCache.get(modelName));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | URL} input
|
||||
* @param {RequestInit} [init]
|
||||
*/
|
||||
export function globalCachedFetch(input, init) {
|
||||
if (init?.method && init.method.toLowerCase() !== "get") {
|
||||
throw new Error(`cannot use a global cached fetch with HTTP method "${init.method}"`);
|
||||
}
|
||||
const key = String(input);
|
||||
if (!(key in globalFetchCache)) {
|
||||
globalFetchCache[key] = realFetch(input, init).catch((reason) => {
|
||||
delete globalFetchCache[key];
|
||||
throw reason;
|
||||
});
|
||||
}
|
||||
return globalFetchCache[key].then((response) => response.clone());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} modelName
|
||||
*/
|
||||
export function registerModelToFetch(modelName) {
|
||||
if (!serverModelCache.has(modelName)) {
|
||||
modelsToFetch.add(modelName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ fileSuffix?: string }} [options]
|
||||
*/
|
||||
export async function runTests(options) {
|
||||
const { fileSuffix = "" } = options || {};
|
||||
// Find dependency issues
|
||||
const errors = loader.findErrors(loader.factories.keys());
|
||||
delete errors.unloaded; // Only a few modules have been loaded yet => irrelevant
|
||||
if (Object.keys(errors).length) {
|
||||
return loader.reportErrors(errors);
|
||||
}
|
||||
|
||||
// Sort modules to accelerate loading time
|
||||
/** @type {Record<string, Deferred>} */
|
||||
const defs = {};
|
||||
/** @type {string[]} */
|
||||
const testModuleNames = [];
|
||||
for (const [name, { deps }] of loader.factories) {
|
||||
// Register test module
|
||||
if (name.endsWith(fileSuffix)) {
|
||||
const baseName = name.slice(0, -fileSuffix.length);
|
||||
testModuleNames.push(baseName);
|
||||
}
|
||||
|
||||
// Register module dependencies
|
||||
const [modDef, ...depDefs] = [name, ...deps].map((dep) => (defs[dep] ||= new Deferred()));
|
||||
Promise.all(depDefs).then(() => {
|
||||
sortedModuleNames.push(name);
|
||||
modDef.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(Object.values(defs));
|
||||
|
||||
// Dry run
|
||||
const { suites } = await dryRun(() => describeDrySuite(fileSuffix, testModuleNames));
|
||||
|
||||
// Run all test files
|
||||
const filteredSuitePaths = new Set(suites.map((s) => s.fullName));
|
||||
let currentAddonsKey = "";
|
||||
for (const moduleName of testModuleNames) {
|
||||
const suitePath = getSuitePath(moduleName);
|
||||
if (!filteredSuitePaths.has(suitePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const moduleSet = await defineModuleSet(fileSuffix, [moduleName], new Set());
|
||||
const moduleSetLoader = new ModuleSetLoader(moduleSet);
|
||||
|
||||
if (currentAddonsKey !== moduleSet.addonsKey) {
|
||||
if (moduleSetLoader.modules.has(TEMPLATE_MODULE_NAME)) {
|
||||
// If templates module is available: set URL filter to filter out
|
||||
// static templates and cleanup current processed templates.
|
||||
const templateModule = moduleSetLoader.modules.get(TEMPLATE_MODULE_NAME);
|
||||
templateModule.setUrlFilters(moduleSet.filter ? [moduleSet.filter] : []);
|
||||
templateModule.clearProcessedTemplates();
|
||||
}
|
||||
currentAddonsKey = moduleSet.addonsKey;
|
||||
}
|
||||
|
||||
const suite = describe(suitePath, () => {
|
||||
moduleSetLoader.setup();
|
||||
moduleSetLoader.startModule(moduleName + fileSuffix);
|
||||
});
|
||||
|
||||
// Run recently added tests
|
||||
const running = await start(suite);
|
||||
|
||||
await moduleSetLoader.cleanup();
|
||||
await __gcAndLogMemory(suite.fullName, suite.reporting.tests);
|
||||
|
||||
if (!running) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await stop();
|
||||
|
||||
// Perform final cleanups
|
||||
moduleNamesCache.clear();
|
||||
serverModelCache.clear();
|
||||
clearObject(dependencies);
|
||||
clearObject(dependencyCache);
|
||||
clearObject(globalFetchCache);
|
||||
const templateModule = loader.modules.get(TEMPLATE_MODULE_NAME);
|
||||
if (templateModule) {
|
||||
templateModule.setUrlFilters([]);
|
||||
templateModule.clearProcessedTemplates();
|
||||
}
|
||||
|
||||
await __gcAndLogMemory("tests done");
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
import { queryAll, queryAllTexts, queryOne, queryText } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { findComponent, mountWithCleanup } from "./component_test_helpers";
|
||||
import { contains } from "./dom_test_helpers";
|
||||
import { getMockEnv, makeMockEnv } from "./env_test_helpers";
|
||||
|
||||
import { WithSearch } from "@web/search/with_search/with_search";
|
||||
import { getDefaultConfig } from "@web/views/view";
|
||||
|
||||
const ensureSearchView = async () => {
|
||||
if (
|
||||
getMockEnv().isSmall &&
|
||||
queryAll`.o_control_panel_navigation`.length &&
|
||||
!queryAll`.o_searchview`.length
|
||||
) {
|
||||
await contains(`.o_control_panel_navigation button`).click();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSearchBarMenu = async () => {
|
||||
if (!queryAll`.o_search_bar_menu`.length) {
|
||||
await toggleSearchBarMenu();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is aim to be used only in the tests.
|
||||
* It will filter the props that are needed by the Component.
|
||||
* This is to avoid errors of props validation. This occurs for example, on ControlPanel tests.
|
||||
* In production, View use WithSearch for the Controllers, and the Layout send only the props that
|
||||
* need to the ControlPanel.
|
||||
*
|
||||
* @param {Component} Component
|
||||
* @param {Object} props
|
||||
* @returns {Object} filtered props
|
||||
*/
|
||||
function filterPropsForComponent(Component, props) {
|
||||
// This if, can be removed once all the Components have the props defined
|
||||
if (Component.props) {
|
||||
let componentKeys = null;
|
||||
if (Component.props instanceof Array) {
|
||||
componentKeys = Component.props.map((x) => x.replace("?", ""));
|
||||
} else {
|
||||
componentKeys = Object.keys(Component.props);
|
||||
}
|
||||
if (componentKeys.includes("*")) {
|
||||
return props;
|
||||
} else {
|
||||
return Object.keys(props)
|
||||
.filter((k) => componentKeys.includes(k))
|
||||
.reduce((o, k) => {
|
||||
o[k] = props[k];
|
||||
return o;
|
||||
}, {});
|
||||
}
|
||||
} else {
|
||||
return props;
|
||||
}
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Search view
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mounts a component wrapped within a WithSearch.
|
||||
*
|
||||
* @template T
|
||||
* @param {T} componentConstructor
|
||||
* @param {Record<string, any>} [options]
|
||||
* @param {Record<string, any>} [config]
|
||||
* @returns {Promise<InstanceType<T>>}
|
||||
*/
|
||||
export async function mountWithSearch(componentConstructor, searchProps = {}, config = {}) {
|
||||
class ComponentWithSearch extends Component {
|
||||
static template = xml`
|
||||
<WithSearch t-props="withSearchProps" t-slot-scope="search">
|
||||
<t t-component="component" t-props="getProps(search)"/>
|
||||
</WithSearch>
|
||||
`;
|
||||
static components = { WithSearch };
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.withSearchProps = searchProps;
|
||||
this.component = componentConstructor;
|
||||
}
|
||||
|
||||
getProps(search) {
|
||||
const props = {
|
||||
context: search.context,
|
||||
domain: search.domain,
|
||||
groupBy: search.groupBy,
|
||||
orderBy: search.orderBy,
|
||||
comparison: search.comparison,
|
||||
display: search.display,
|
||||
};
|
||||
return filterPropsForComponent(componentConstructor, props);
|
||||
}
|
||||
}
|
||||
|
||||
const fullConfig = { ...getDefaultConfig(), ...config };
|
||||
const env = await makeMockEnv({ config: fullConfig });
|
||||
const root = await mountWithCleanup(ComponentWithSearch, { env });
|
||||
return findComponent(root, (component) => component instanceof componentConstructor);
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Menu (generic)
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
*/
|
||||
export async function toggleMenu(label) {
|
||||
await contains(`button.o-dropdown:contains(/^${label}$/i)`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
*/
|
||||
export async function toggleMenuItem(label) {
|
||||
const target = queryOne`.o_menu_item:contains(/^${label}$/i)`;
|
||||
if (target.classList.contains("dropdown-toggle")) {
|
||||
await contains(target).hover();
|
||||
} else {
|
||||
await contains(target).click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} itemLabel
|
||||
* @param {string} optionLabel
|
||||
*/
|
||||
export async function toggleMenuItemOption(itemLabel, optionLabel) {
|
||||
const { parentElement: root } = queryOne`.o_menu_item:contains(/^${itemLabel}$/i)`;
|
||||
const target = queryOne(`.o_item_option:contains(/^${optionLabel}$/i)`, { root });
|
||||
if (target.classList.contains("dropdown-toggle")) {
|
||||
await contains(target).hover();
|
||||
} else {
|
||||
await contains(target).click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
*/
|
||||
export function isItemSelected(label) {
|
||||
return queryOne`.o_menu_item:contains(/^${label}$/i)`.classList.contains("selected");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} itemLabel
|
||||
* @param {string} optionLabel
|
||||
*/
|
||||
export function isOptionSelected(itemLabel, optionLabel) {
|
||||
const { parentElement: root } = queryOne`.o_menu_item:contains(/^${itemLabel}$/i)`;
|
||||
return queryOne(`.o_item_option:contains(/^${optionLabel}$/i)`, { root }).classList.contains(
|
||||
"selected"
|
||||
);
|
||||
}
|
||||
|
||||
export function getMenuItemTexts() {
|
||||
return queryAllTexts`.dropdown-menu .o_menu_item`;
|
||||
}
|
||||
|
||||
export function getButtons() {
|
||||
return queryAll`.o_control_panel_breadcrumbs button`;
|
||||
}
|
||||
|
||||
export function getVisibleButtons() {
|
||||
return queryAll`.o_control_panel_breadcrumbs button:visible, .o_control_panel_actions button:visible`;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Filter menu
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export async function toggleFilterMenu() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_filter_menu button.dropdown-toggle`).click();
|
||||
}
|
||||
|
||||
export async function openAddCustomFilterDialog() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_filter_menu .o_menu_item.o_add_custom_filter`).click();
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Group by menu
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export async function toggleGroupByMenu() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_group_by_menu .dropdown-toggle`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fieldName
|
||||
*/
|
||||
export async function selectGroup(fieldName) {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_add_custom_group_menu`).select(fieldName);
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Favorite menu
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export async function toggleFavoriteMenu() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_favorite_menu .dropdown-toggle`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
export async function deleteFavorite(text) {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_favorite_menu .o_menu_item:contains(/^${text}$/i) i.fa-trash-o`).click();
|
||||
}
|
||||
|
||||
export async function toggleSaveFavorite() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_favorite_menu .o_add_favorite`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function editFavoriteName(name) {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(
|
||||
`.o_favorite_menu .o_add_favorite + .o_accordion_values input[type="text"]`
|
||||
).edit(name, { confirm: false });
|
||||
}
|
||||
|
||||
export async function saveFavorite() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_favorite_menu .o_add_favorite + .o_accordion_values button`).click();
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Comparison menu
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export async function toggleComparisonMenu() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_comparison_menu button.dropdown-toggle`).click();
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Search bar
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function getFacetTexts() {
|
||||
return queryAllTexts(`.o_searchview_facet`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
*/
|
||||
export async function removeFacet(label) {
|
||||
await ensureSearchView();
|
||||
await contains(`.o_searchview_facet:contains(/^${label}$/i) .o_facet_remove`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export async function editSearch(value) {
|
||||
await ensureSearchView();
|
||||
await contains(`.o_searchview input`).edit(value, { confirm: false });
|
||||
}
|
||||
|
||||
export async function validateSearch() {
|
||||
await ensureSearchView();
|
||||
await contains(`.o_searchview input`).press("Enter");
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Switch view
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {import("./mock_server/mock_server").ViewType} viewType
|
||||
*/
|
||||
export async function switchView(viewType) {
|
||||
await contains(`button.o_switch_view.o_${viewType}`).click();
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Pager
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
export function getPagerValue(root) {
|
||||
return queryText(".o_pager .o_pager_value", { root })
|
||||
.split(/\s*-\s*/)
|
||||
.map(Number);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
export function getPagerLimit(root) {
|
||||
return parseInt(queryText(".o_pager .o_pager_limit", { root }), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
export async function pagerNext(root) {
|
||||
await contains(".o_pager button.o_pager_next", { root }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
export async function pagerPrevious(root) {
|
||||
await contains(".o_pager button.o_pager_previous", { root }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export async function editPager(value) {
|
||||
await contains(`.o_pager .o_pager_limit`).edit(value);
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Action Menu
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {string} [menuFinder="Action"]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function toggleActionMenu() {
|
||||
await contains(".o_cp_action_menus .dropdown-toggle").click();
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Search bar menu
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export async function toggleSearchBarMenu() {
|
||||
await ensureSearchView();
|
||||
await contains(`.o_searchview_dropdown_toggler`).click();
|
||||
}
|
||||
|
|
@ -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" }));
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { after } from "@odoo/hoot";
|
||||
import { serverState } from "./mock_server_state.hoot";
|
||||
import { patchWithCleanup } from "./patch_test_helpers";
|
||||
|
||||
import { loadLanguages, translatedTerms, translationLoaded } from "@web/core/l10n/translation";
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} languages
|
||||
*/
|
||||
export function installLanguages(languages) {
|
||||
serverState.multiLang = true;
|
||||
patchWithCleanup(loadLanguages, {
|
||||
installedLanguages: Object.entries(languages),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} [terms]
|
||||
*/
|
||||
export function patchTranslations(terms = {}) {
|
||||
translatedTerms[translationLoaded] = true;
|
||||
after(() => {
|
||||
translatedTerms[translationLoaded] = false;
|
||||
});
|
||||
patchWithCleanup(translatedTerms, terms);
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
import { after, expect, getFixture } from "@odoo/hoot";
|
||||
import { click, formatXml, queryAll, queryAllTexts } from "@odoo/hoot-dom";
|
||||
import { animationFrame, Deferred, tick } from "@odoo/hoot-mock";
|
||||
import { Component, onMounted, useSubEnv, xml } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { View } from "@web/views/view";
|
||||
import { mountWithCleanup } from "./component_test_helpers";
|
||||
import { contains } from "./dom_test_helpers";
|
||||
import { getService } from "./env_test_helpers";
|
||||
import { registerInlineViewArchs } from "./mock_server/mock_model";
|
||||
|
||||
/**
|
||||
* @typedef {import("@web/views/view").Config} Config
|
||||
*
|
||||
* @typedef {ViewProps & {
|
||||
* archs?: Record<string, string>
|
||||
* config?: Config;
|
||||
* env?: import("@web/env").OdooEnv;
|
||||
* resId?: number;
|
||||
* [key: string]: any;
|
||||
* }} MountViewParams
|
||||
*
|
||||
* @typedef {{
|
||||
* class?: string;
|
||||
* id?: string;
|
||||
* index?: number;
|
||||
* modifier?: string;
|
||||
* target?: string;
|
||||
* text?: string;
|
||||
* }} SelectorOptions
|
||||
*
|
||||
* @typedef {import("@odoo/hoot-dom").FormatXmlOptions} FormatXmlOptions
|
||||
* @typedef {import("@web/views/view").ViewProps} ViewProps
|
||||
* @typedef {import("./mock_server/mock_model").ViewType} ViewType
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internals
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* FIXME: isolate to external helper in @web?
|
||||
*
|
||||
* @param {unknown} value
|
||||
*/
|
||||
const isNil = (value) => value === null || value === undefined;
|
||||
|
||||
class ViewDialog extends Component {
|
||||
static components = { Dialog, View };
|
||||
|
||||
static props = {
|
||||
onMounted: Function,
|
||||
viewEnv: Object,
|
||||
viewProps: Object,
|
||||
close: Function,
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
<View t-props="props.viewProps" />
|
||||
</Dialog>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
useSubEnv(this.props.viewEnv);
|
||||
onMounted(() => this.props.onMounted());
|
||||
}
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} base
|
||||
* @param {SelectorOptions} [params]
|
||||
*/
|
||||
export function buildSelector(base, params) {
|
||||
let selector = base;
|
||||
params ||= {};
|
||||
if (params.id) {
|
||||
selector += `#${params.id}`;
|
||||
}
|
||||
if (params.class) {
|
||||
selector += `.${params.class}`;
|
||||
}
|
||||
if (params.modifier) {
|
||||
selector += `:${params.modifier}`;
|
||||
}
|
||||
if (params.text) {
|
||||
selector += `:contains(${params.text})`;
|
||||
}
|
||||
if (!isNil(params.index)) {
|
||||
selector += `:eq(${params.index})`;
|
||||
}
|
||||
if (params.target) {
|
||||
selector += ` ${params.target}`;
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickButton(options) {
|
||||
await contains(buildSelector(`.btn:enabled`, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickCancel(options) {
|
||||
await contains(buildSelector(`.o_form_button_cancel:enabled`, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fieldName
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickFieldDropdown(fieldName, options) {
|
||||
await contains(buildSelector(`[name='${fieldName}'] .dropdown input`, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fieldName
|
||||
* @param {string} itemContent
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickFieldDropdownItem(fieldName, itemContent, options) {
|
||||
const dropdowns = queryAll(
|
||||
buildSelector(`[name='${fieldName}'] .dropdown .dropdown-menu`, options)
|
||||
);
|
||||
if (dropdowns.length === 0) {
|
||||
throw new Error(`No dropdown found for field ${fieldName}`);
|
||||
} else if (dropdowns.length > 1) {
|
||||
throw new Error(`Found ${dropdowns.length} dropdowns for field ${fieldName}`);
|
||||
}
|
||||
const dropdownItems = queryAll(buildSelector("li", options), { root: dropdowns[0] });
|
||||
const indexToClick = queryAllTexts(dropdownItems).indexOf(itemContent);
|
||||
if (indexToClick === -1) {
|
||||
throw new Error(`The element '${itemContent}' does not exist in the dropdown`);
|
||||
}
|
||||
await click(dropdownItems[indexToClick]);
|
||||
await animationFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickModalButton(options) {
|
||||
await contains(buildSelector(`.modal .btn:enabled`, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickSave(options) {
|
||||
await contains(buildSelector(`.o_form_button_save:enabled`, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickViewButton(options) {
|
||||
await contains(buildSelector(`.o_view_controller .btn:enabled`, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export function expectMarkup(value) {
|
||||
return {
|
||||
/**
|
||||
* @param {string} expected
|
||||
* @param {FormatXmlOptions} [options]
|
||||
*/
|
||||
toBe(expected, options) {
|
||||
expect(formatXml(value, options)).toBe(formatXml(expected, options));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {SelectorOptions} options
|
||||
*/
|
||||
export function fieldInput(name, options) {
|
||||
return contains(buildSelector(`.o_field_widget[name='${name}'] input`, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MountViewParams} params
|
||||
*/
|
||||
export async function mountViewInDialog(params) {
|
||||
const container = await mountWithCleanup(MainComponentsContainer, {
|
||||
env: params.env,
|
||||
});
|
||||
const deferred = new Deferred();
|
||||
getService("dialog").add(ViewDialog, {
|
||||
viewEnv: { config: params.config },
|
||||
viewProps: parseViewProps(params),
|
||||
onMounted() {
|
||||
deferred.resolve();
|
||||
},
|
||||
});
|
||||
await deferred;
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MountViewParams} params
|
||||
* @param {HTMLElement} [target]
|
||||
*/
|
||||
export async function mountView(params, target = null) {
|
||||
const actionManagerEl = document.createElement("div");
|
||||
actionManagerEl.classList.add("o_action_manager");
|
||||
(target ?? getFixture()).append(actionManagerEl);
|
||||
after(() => actionManagerEl.remove());
|
||||
return mountWithCleanup(View, {
|
||||
env: params.env,
|
||||
componentEnv: { config: params.config },
|
||||
props: parseViewProps(params),
|
||||
target: actionManagerEl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ViewProps & { archs?: Record<string, string> }} props
|
||||
* @returns {ViewProps}
|
||||
*/
|
||||
export function parseViewProps(props) {
|
||||
let className = "o_action";
|
||||
if (props.className) {
|
||||
className += " " + props.className;
|
||||
}
|
||||
|
||||
const viewProps = { ...props, className };
|
||||
|
||||
if (
|
||||
props.archs ||
|
||||
!isNil(props.arch) ||
|
||||
!isNil(props.searchViewArch) ||
|
||||
!isNil(props.searchViewId) ||
|
||||
!isNil(props.viewId)
|
||||
) {
|
||||
viewProps.viewId ??= -1;
|
||||
viewProps.searchViewId ??= -1;
|
||||
registerInlineViewArchs(viewProps.resModel, {
|
||||
...props.archs,
|
||||
[[viewProps.type, viewProps.viewId]]: viewProps.arch,
|
||||
[["search", viewProps.searchViewId]]: viewProps.searchViewArch,
|
||||
});
|
||||
} else {
|
||||
// Force `get_views` call
|
||||
viewProps.viewId = false;
|
||||
viewProps.searchViewId = false;
|
||||
}
|
||||
|
||||
delete viewProps.arch;
|
||||
delete viewProps.archs;
|
||||
delete viewProps.config;
|
||||
delete viewProps.searchViewArch;
|
||||
|
||||
return viewProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a field dropdown and click on the item which matches the
|
||||
* given content
|
||||
* @param {string} fieldName
|
||||
* @param {string} itemContent
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function selectFieldDropdownItem(fieldName, itemContent, options) {
|
||||
await clickFieldDropdown(fieldName, options);
|
||||
await clickFieldDropdownItem(fieldName, itemContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emulates the behaviour when we hide the tab in the browser.
|
||||
*/
|
||||
export async function hideTab() {
|
||||
const prop = Object.getOwnPropertyDescriptor(Document.prototype, "visibilityState");
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await tick();
|
||||
Object.defineProperty(document, "visibilityState", prop);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
import { mountWithCleanup } from "./component_test_helpers";
|
||||
|
||||
class TestClientAction extends Component {
|
||||
static template = xml`
|
||||
<div class="test_client_action">
|
||||
ClientAction_<t t-esc="props.action.params?.description"/>
|
||||
</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
export function useTestClientAction() {
|
||||
const tag = "__test__client__action__";
|
||||
registry.category("actions").add(tag, TestClientAction);
|
||||
return {
|
||||
tag,
|
||||
target: "main",
|
||||
type: "ir.actions.client",
|
||||
params: { description: "Id 1" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Parameters<typeof mountWithCleanup>[1]} [options]
|
||||
*/
|
||||
export async function mountWebClient(options) {
|
||||
await mountWithCleanup(WebClient, options);
|
||||
// Wait for visual changes caused by a potential loadState
|
||||
await animationFrame();
|
||||
// wait for BlankComponent
|
||||
await animationFrame();
|
||||
// wait for the regular rendering
|
||||
await animationFrame();
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,817 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
isInViewPort,
|
||||
isScrollable,
|
||||
pointerDown,
|
||||
pointerUp,
|
||||
press,
|
||||
queryAllAttributes,
|
||||
queryAllTexts,
|
||||
queryFirst,
|
||||
queryOne,
|
||||
queryRect,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { Deferred, animationFrame, runAllTimers } from "@odoo/hoot-mock";
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
|
||||
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
|
||||
/**
|
||||
* Helper needed until `isInViewPort` also checks intermediate parent elements.
|
||||
* This is to make sure an element is actually visible, not just "within
|
||||
* viewport boundaries" but below or above a parent's scroll point.
|
||||
*
|
||||
* @param {import("@odoo/hoot-dom").Target} target
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isInViewWithinScrollableY(target) {
|
||||
const element = queryFirst(target);
|
||||
let container = element.parentElement;
|
||||
while (
|
||||
container &&
|
||||
(container.scrollHeight <= container.clientHeight ||
|
||||
!["auto", "scroll"].includes(getComputedStyle(container).overflowY))
|
||||
) {
|
||||
container = container.parentElement;
|
||||
}
|
||||
if (!container) {
|
||||
return isInViewPort(element);
|
||||
}
|
||||
const { x, y } = queryRect(element);
|
||||
const { height: containerHeight, width: containerWidth } = queryRect(container);
|
||||
return y > 0 && y < containerHeight && x > 0 && x < containerWidth;
|
||||
}
|
||||
|
||||
test("can be rendered", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
/>
|
||||
`;
|
||||
static props = {};
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete").toHaveCount(1);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
expect(queryAllTexts(".o-autocomplete--dropdown-item")).toEqual(["World", "Hello"]);
|
||||
|
||||
const dropdownItemIds = queryAllAttributes(".dropdown-item", "id");
|
||||
expect(dropdownItemIds).toEqual(["autocomplete_0_0", "autocomplete_0_1"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "role")).toEqual(["option", "option"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "aria-selected")).toEqual(["true", "false"]);
|
||||
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[0]);
|
||||
});
|
||||
|
||||
test("select option", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<AutoComplete
|
||||
value="state.value"
|
||||
sources="sources"
|
||||
onSelect="(option) => this.onSelect(option)"
|
||||
/>
|
||||
`;
|
||||
static props = {};
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: "Hello",
|
||||
});
|
||||
}
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
options: [{ label: "World" }, { label: "Hello" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
onSelect(option) {
|
||||
this.state.value = option.label;
|
||||
expect.step(option.label);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
|
||||
expect(".o-autocomplete input").toHaveValue("World");
|
||||
expect.verifySteps(["World"]);
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(".o-autocomplete--dropdown-item:last").click();
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
expect.verifySteps(["Hello"]);
|
||||
});
|
||||
|
||||
test("autocomplete with resetOnSelect='true'", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<div>
|
||||
<div class= "test_value" t-esc="state.value"/>
|
||||
<AutoComplete
|
||||
value="''"
|
||||
sources="sources"
|
||||
onSelect="(option) => this.onSelect(option)"
|
||||
resetOnSelect="true"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
static props = {};
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: "Hello",
|
||||
});
|
||||
}
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
options: [{ label: "World" }, { label: "Hello" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
onSelect(option) {
|
||||
this.state.value = option.label;
|
||||
expect.step(option.label);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".test_value").toHaveText("Hello");
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
|
||||
await contains(".o-autocomplete input").edit("Blip", { confirm: false });
|
||||
await runAllTimers();
|
||||
await contains(".o-autocomplete--dropdown-item:last").click();
|
||||
expect(".test_value").toHaveText("Hello");
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
expect.verifySteps(["Hello"]);
|
||||
});
|
||||
|
||||
test("open dropdown on input", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
/>
|
||||
`;
|
||||
static props = {};
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
await contains(".o-autocomplete input").fill("a", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("cancel result on escape keydown", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
autoSelect="true"
|
||||
/>
|
||||
`;
|
||||
static props = {};
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(".o-autocomplete input").edit("H", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
|
||||
await contains(".o-autocomplete input").press("Escape");
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
});
|
||||
|
||||
test("select input text on first focus", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<AutoComplete value="'Bar'" sources="[{ options: [{ label: 'Bar' }] }]" onSelect="() => {}"/>
|
||||
`;
|
||||
static props = {};
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
expect(getSelection().toString()).toBe("Bar");
|
||||
});
|
||||
|
||||
test("scroll outside should cancel result", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<div class="autocomplete_container overflow-auto" style="max-height: 100px;">
|
||||
<div style="height: 1000px;">
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
autoSelect="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
static props = {};
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(".o-autocomplete input").edit("H", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
|
||||
await contains(".autocomplete_container").scroll({ top: 10 });
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
});
|
||||
|
||||
test("scroll inside should keep dropdown open", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<div class="autocomplete_container overflow-auto" style="max-height: 100px;">
|
||||
<div style="height: 1000px;">
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
static props = {};
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
|
||||
await contains(".o-autocomplete .dropdown-menu").scroll({ top: 10 });
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("losing focus should cancel result", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
autoSelect="true"
|
||||
/>
|
||||
`;
|
||||
static props = {};
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(".o-autocomplete input").edit("H", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
|
||||
await contains(document.body).click();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
});
|
||||
|
||||
test("click out after clearing input", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
/>
|
||||
`;
|
||||
static props = {};
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(".o-autocomplete input").clear({ confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
|
||||
await contains(document.body).click();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
});
|
||||
|
||||
test("open twice should not display previous results", async () => {
|
||||
let def = new Deferred();
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<AutoComplete value="''" sources="sources" onSelect="() => {}"/>
|
||||
`;
|
||||
static props = {};
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
async options(search) {
|
||||
await def;
|
||||
if (search === "A") {
|
||||
return [{ label: "AB" }, { label: "AC" }];
|
||||
}
|
||||
return [{ label: "AB" }, { label: "AC" }, { label: "BC" }];
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(1);
|
||||
expect(".o-autocomplete--dropdown-item .fa-spin").toHaveCount(1); // loading
|
||||
|
||||
def.resolve();
|
||||
await animationFrame();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(3);
|
||||
expect(".fa-spin").toHaveCount(0);
|
||||
|
||||
def = new Deferred();
|
||||
await contains(".o-autocomplete input").fill("A", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(1);
|
||||
expect(".o-autocomplete--dropdown-item .fa-spin").toHaveCount(1); // loading
|
||||
def.resolve();
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(2);
|
||||
expect(".fa-spin").toHaveCount(0);
|
||||
|
||||
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
// re-open the dropdown -> should not display the previous results
|
||||
def = new Deferred();
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(1);
|
||||
expect(".o-autocomplete--dropdown-item .fa-spin").toHaveCount(1); // loading
|
||||
});
|
||||
|
||||
test("press enter on autocomplete with empty source", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="''" sources="sources" onSelect="onSelect"/>`;
|
||||
static props = {};
|
||||
get sources() {
|
||||
return [{ options: [] }];
|
||||
}
|
||||
onSelect() {}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
// click inside the input and press "enter", because why not
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
await contains(".o-autocomplete input").press("Enter");
|
||||
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("press enter on autocomplete with empty source (2)", async () => {
|
||||
// in this test, the source isn't empty at some point, but becomes empty as the user
|
||||
// updates the input's value.
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="''" sources="sources" onSelect="onSelect"/>`;
|
||||
static props = {};
|
||||
get sources() {
|
||||
const options = (val) => {
|
||||
if (val.length > 2) {
|
||||
return [{ label: "test A" }, { label: "test B" }, { label: "test C" }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
return [{ options }];
|
||||
}
|
||||
onSelect() {}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
|
||||
await contains(".o-autocomplete input").edit("test", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
expect(".o-autocomplete .dropdown-menu .o-autocomplete--dropdown-item").toHaveCount(3);
|
||||
|
||||
await contains(".o-autocomplete input").edit("t", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
await contains(".o-autocomplete input").press("Enter");
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
expect(".o-autocomplete input").toHaveValue("t");
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("autofocus=true option work as expected", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<AutoComplete value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
autofocus="true"
|
||||
onSelect="() => {}"
|
||||
/>
|
||||
`;
|
||||
static props = {};
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(".o-autocomplete input").toBeFocused();
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("autocomplete in edition keep edited value before select option", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<button class="myButton" t-on-mouseover="onHover">My button</button>
|
||||
<AutoComplete value="this.state.value"
|
||||
sources="[{ options: [{ label: 'My Selection' }] }]"
|
||||
onSelect.bind="onSelect"
|
||||
/>
|
||||
`;
|
||||
static props = {};
|
||||
setup() {
|
||||
this.state = useState({ value: "Hello" });
|
||||
}
|
||||
|
||||
onHover() {
|
||||
this.state.value = "My Click";
|
||||
}
|
||||
|
||||
onSelect() {
|
||||
this.state.value = "My Selection";
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await contains(".o-autocomplete input").edit("Yolo", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete input").toHaveValue("Yolo");
|
||||
|
||||
// We want to simulate an external value edition (like a delayed onChange)
|
||||
await contains(".myButton").hover();
|
||||
expect(".o-autocomplete input").toHaveValue("Yolo");
|
||||
|
||||
// Leave inEdition mode when selecting an option
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
|
||||
expect(".o-autocomplete input").toHaveValue("My Selection");
|
||||
|
||||
// Will also trigger the hover event
|
||||
await contains(".myButton").click();
|
||||
expect(".o-autocomplete input").toHaveValue("My Click");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("autocomplete in edition keep edited value before blur", async () => {
|
||||
let count = 0;
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<button class="myButton" t-on-mouseover="onHover">My button</button>
|
||||
<AutoComplete value="this.state.value"
|
||||
sources="[]"
|
||||
onSelect="() => {}"
|
||||
/>
|
||||
`;
|
||||
static props = {};
|
||||
setup() {
|
||||
this.state = useState({ value: "Hello" });
|
||||
}
|
||||
|
||||
onHover() {
|
||||
this.state.value = `My Click ${count++}`;
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await contains(".o-autocomplete input").edit("", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
|
||||
// We want to simulate an external value edition (like a delayed onChange)
|
||||
await contains(".myButton").hover();
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
|
||||
// Leave inEdition mode when blur the input
|
||||
await contains(document.body).click();
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
|
||||
// Will also trigger the hover event
|
||||
await contains(".myButton").click();
|
||||
expect(".o-autocomplete input").toHaveValue("My Click 1");
|
||||
});
|
||||
|
||||
test("correct sequence of blur, focus and select", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<AutoComplete
|
||||
value="state.value"
|
||||
sources="sources"
|
||||
onSelect.bind="onSelect"
|
||||
onBlur.bind="onBlur"
|
||||
onChange.bind="onChange"
|
||||
autoSelect="true"
|
||||
/>
|
||||
`;
|
||||
static props = {};
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
options: [{ label: "World" }, { label: "Hello" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
onChange() {
|
||||
expect.step("change");
|
||||
}
|
||||
onSelect(option, params) {
|
||||
queryOne(".o-autocomplete input").value = option.label;
|
||||
expect.step("select " + option.label);
|
||||
expect(params.triggeredOnBlur).not.toBe(true);
|
||||
}
|
||||
onBlur() {
|
||||
expect.step("blur");
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
await contains(".o-autocomplete input").click();
|
||||
|
||||
// Navigate suggestions using arrow keys
|
||||
let dropdownItemIds = queryAllAttributes(".dropdown-item", "id");
|
||||
expect(dropdownItemIds).toEqual(["autocomplete_0_0", "autocomplete_0_1"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "role")).toEqual(["option", "option"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "aria-selected")).toEqual(["true", "false"]);
|
||||
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[0]);
|
||||
|
||||
await contains(".o-autocomplete--input").press("ArrowDown");
|
||||
|
||||
dropdownItemIds = queryAllAttributes(".dropdown-item", "id");
|
||||
expect(dropdownItemIds).toEqual(["autocomplete_0_0", "autocomplete_0_1"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "role")).toEqual(["option", "option"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "aria-selected")).toEqual(["false", "true"]);
|
||||
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[1]);
|
||||
|
||||
// Start typing hello and click on the result
|
||||
await contains(".o-autocomplete input").edit("h", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
await contains(".o-autocomplete--dropdown-item:last").click();
|
||||
expect.verifySteps(["change", "select Hello"]);
|
||||
expect(".o-autocomplete input").toBeFocused();
|
||||
|
||||
// Clear input and focus out
|
||||
await contains(".o-autocomplete input").edit("", { confirm: false });
|
||||
await runAllTimers();
|
||||
await contains(document.body).click();
|
||||
expect.verifySteps(["blur", "change"]);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("autocomplete always closes on click away", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<AutoComplete
|
||||
value="state.value"
|
||||
sources="sources"
|
||||
onSelect.bind="onSelect"
|
||||
autoSelect="true"
|
||||
/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { AutoComplete };
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
options: [{ label: "World" }, { label: "Hello" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
onSelect(option) {
|
||||
queryOne(".o-autocomplete--input").value = option.label;
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
await contains(".o-autocomplete input").click();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(2);
|
||||
await pointerDown(".o-autocomplete--dropdown-item:last");
|
||||
await pointerUp(document.body);
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(2);
|
||||
await contains(document.body).click();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("autocomplete trim spaces for search", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<AutoComplete value="state.value" sources="sources" onSelect="() => {}"/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { AutoComplete };
|
||||
setup() {
|
||||
this.state = useState({ value: " World" });
|
||||
}
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
options(search) {
|
||||
return [{ label: "World" }, { label: "Hello" }].filter(({ label }) =>
|
||||
label.startsWith(search)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
await contains(`.o-autocomplete input`).click();
|
||||
expect(queryAllTexts(`.o-autocomplete--dropdown-item`)).toEqual(["World", "Hello"]);
|
||||
});
|
||||
|
||||
test("tab and shift+tab close the dropdown", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<AutoComplete value="state.value" sources="sources" onSelect="() => {}"/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { AutoComplete };
|
||||
setup() {
|
||||
this.state = useState({ value: "" });
|
||||
}
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
options: [{ label: "World" }, { label: "Hello" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
const input = ".o-autocomplete input";
|
||||
const dropdown = ".o-autocomplete--dropdown-menu";
|
||||
expect(input).toHaveCount(1);
|
||||
// Tab
|
||||
await contains(input).click();
|
||||
expect(dropdown).toBeVisible();
|
||||
await press("Tab");
|
||||
await animationFrame();
|
||||
expect(dropdown).not.toHaveCount();
|
||||
// Shift + Tab
|
||||
await contains(input).click();
|
||||
expect(dropdown).toBeVisible();
|
||||
await press("Tab", { shiftKey: true });
|
||||
await animationFrame();
|
||||
expect(dropdown).not.toHaveCount();
|
||||
});
|
||||
|
||||
test("autocomplete scrolls when moving with arrows", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<style>
|
||||
.o-autocomplete--dropdown-menu {
|
||||
max-height: 100px;
|
||||
}
|
||||
</style>
|
||||
<AutoComplete
|
||||
value="state.value"
|
||||
sources="sources"
|
||||
onSelect="() => {}"
|
||||
autoSelect="true"
|
||||
/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { AutoComplete };
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
options: [
|
||||
{ label: "Never" },
|
||||
{ label: "Gonna" },
|
||||
{ label: "Give" },
|
||||
{ label: "You" },
|
||||
{ label: "Up" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
const dropdownSelector = ".o-autocomplete--dropdown-menu";
|
||||
const activeItemSelector = ".o-autocomplete--dropdown-item .ui-state-active";
|
||||
const msgInView = "active item should be in view within dropdown";
|
||||
const msgNotInView = "item should not be in view within dropdown";
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
// Open with arrow key.
|
||||
await contains(".o-autocomplete input").focus();
|
||||
await press("ArrowDown");
|
||||
await animationFrame();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(5);
|
||||
expect(isScrollable(dropdownSelector)).toBe(true, { message: "dropdown should be scrollable" });
|
||||
// First element focused and visible (dropdown is not scrolled yet).
|
||||
expect(".o-autocomplete--dropdown-item:first-child a").toHaveClass("ui-state-active");
|
||||
expect(isInViewWithinScrollableY(activeItemSelector)).toBe(true, { message: msgInView });
|
||||
// Navigate with the arrow keys. Go to the last item.
|
||||
expect(isInViewWithinScrollableY(".o-autocomplete--dropdown-item:contains('Up')")).toBe(false, {
|
||||
message: "'Up' " + msgNotInView,
|
||||
});
|
||||
await press("ArrowUp");
|
||||
await press("ArrowUp");
|
||||
await animationFrame();
|
||||
expect(activeItemSelector).toHaveText("Up");
|
||||
expect(isInViewWithinScrollableY(activeItemSelector)).toBe(true, { message: msgInView });
|
||||
// Navigate to an item that is not currently visible.
|
||||
expect(isInViewWithinScrollableY(".o-autocomplete--dropdown-item:contains('Never')")).toBe(
|
||||
false,
|
||||
{ message: "'Never' " + msgNotInView }
|
||||
);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await press("ArrowUp");
|
||||
}
|
||||
await animationFrame();
|
||||
expect(activeItemSelector).toHaveText("Never");
|
||||
expect(isInViewWithinScrollableY(activeItemSelector)).toBe(true, { message: msgInView });
|
||||
expect(isInViewWithinScrollableY(".o-autocomplete--dropdown-item:last")).toBe(false, {
|
||||
message: "last " + msgNotInView,
|
||||
});
|
||||
});
|
||||
|
|
@ -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!
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
135
odoo-bringout-oca-ocb-web/web/static/tests/core/checkbox.test.js
Normal file
135
odoo-bringout-oca-ocb-web/web/static/tests/core/checkbox.test.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { check, uncheck } from "@odoo/hoot-dom";
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { contains, defineParams, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { CheckBox } from "@web/core/checkbox/checkbox";
|
||||
|
||||
test("can be rendered", async () => {
|
||||
await mountWithCleanup(CheckBox);
|
||||
|
||||
expect(`.o-checkbox input[type=checkbox]`).toHaveCount(1);
|
||||
expect(`.o-checkbox input[type=checkbox]`).toBeEnabled();
|
||||
});
|
||||
|
||||
test("has a slot for translatable text", async () => {
|
||||
defineParams({ translations: { ragabadabadaba: "rugubudubudubu" } });
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { CheckBox };
|
||||
static props = {};
|
||||
static template = xml`<CheckBox>ragabadabadaba</CheckBox>`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(`.form-check`).toHaveCount(1);
|
||||
expect(`.form-check`).toHaveText("rugubudubudubu", { exact: true });
|
||||
});
|
||||
|
||||
test("call onChange prop when some change occurs", async () => {
|
||||
let value = false;
|
||||
class Parent extends Component {
|
||||
static components = { CheckBox };
|
||||
static props = {};
|
||||
static template = xml`<CheckBox onChange="onChange" />`;
|
||||
onChange(checked) {
|
||||
value = checked;
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(`.o-checkbox input`).toHaveCount(1);
|
||||
|
||||
await check("input");
|
||||
|
||||
expect(value).toBe(true);
|
||||
|
||||
await uncheck("input");
|
||||
|
||||
expect(value).toBe(false);
|
||||
});
|
||||
|
||||
test("checkbox with props disabled", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CheckBox };
|
||||
static props = {};
|
||||
static template = xml`<CheckBox disabled="true" />`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(`.o-checkbox input`).toHaveCount(1);
|
||||
expect(`.o-checkbox input`).not.toBeEnabled();
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("can toggle value by pressing ENTER", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CheckBox };
|
||||
static props = {};
|
||||
static template = xml`<CheckBox onChange.bind="onChange" value="state.value" />`;
|
||||
|
||||
setup() {
|
||||
this.state = useState({ value: false });
|
||||
}
|
||||
|
||||
onChange(checked) {
|
||||
this.state.value = checked;
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(`.o-checkbox input`).toHaveCount(1);
|
||||
expect(`.o-checkbox input`).not.toBeChecked();
|
||||
|
||||
await contains(".o-checkbox input").press("Enter");
|
||||
|
||||
expect(`.o-checkbox input`).toBeChecked();
|
||||
|
||||
await contains(".o-checkbox input").press("Enter");
|
||||
|
||||
expect(`.o-checkbox input`).not.toBeChecked();
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("toggling through multiple ways", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CheckBox };
|
||||
static props = {};
|
||||
static template = xml`<CheckBox onChange.bind="onChange" value="state.value" />`;
|
||||
|
||||
setup() {
|
||||
this.state = useState({ value: false });
|
||||
}
|
||||
|
||||
onChange(checked) {
|
||||
this.state.value = checked;
|
||||
expect.step(String(checked));
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(`.o-checkbox input`).toHaveCount(1);
|
||||
expect(`.o-checkbox input`).not.toBeChecked();
|
||||
|
||||
await contains(".o-checkbox").click();
|
||||
|
||||
expect(`.o-checkbox input`).toBeChecked();
|
||||
|
||||
await contains(".o-checkbox > .form-check-label", { visible: false }).uncheck();
|
||||
|
||||
expect(`.o-checkbox input`).not.toBeChecked();
|
||||
|
||||
await contains(".o-checkbox input").press("Enter");
|
||||
|
||||
expect(`.o-checkbox input`).toBeChecked();
|
||||
|
||||
await contains(".o-checkbox input").press(" ");
|
||||
|
||||
expect(`.o-checkbox input`).not.toBeChecked();
|
||||
expect.verifySteps(["true", "false", "true", "false"]);
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { ColorList } from "@web/core/colorlist/colorlist";
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<t t-component="Component" t-props="componentProps"/>
|
||||
<div class="outsideDiv">Outside div</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
|
||||
get Component() {
|
||||
return this.props.Component || ColorList;
|
||||
}
|
||||
|
||||
get componentProps() {
|
||||
const props = { ...this.props };
|
||||
delete props.Component;
|
||||
if (!props.onColorSelected) {
|
||||
props.onColorSelected = () => {};
|
||||
}
|
||||
return props;
|
||||
}
|
||||
}
|
||||
|
||||
test("basic rendering with forceExpanded props", async () => {
|
||||
await mountWithCleanup(Parent, {
|
||||
props: {
|
||||
colors: [0, 9],
|
||||
forceExpanded: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_colorlist").toHaveCount(1);
|
||||
expect(".o_colorlist button").toHaveCount(2);
|
||||
expect(".o_colorlist button:eq(1)").toHaveAttribute("title", "Raspberry");
|
||||
expect(".o_colorlist button:eq(1)").toHaveClass("o_colorlist_item_color_9");
|
||||
});
|
||||
|
||||
test("color click does not open the list if canToggle props is not given", async () => {
|
||||
const selectedColorId = 0;
|
||||
await mountWithCleanup(Parent, {
|
||||
props: {
|
||||
colors: [4, 5, 6],
|
||||
selectedColor: selectedColorId,
|
||||
onColorSelected: (colorId) => expect.step("color #" + colorId + " is selected"),
|
||||
},
|
||||
});
|
||||
expect(".o_colorlist").toHaveCount(1);
|
||||
expect("button.o_colorlist_toggler").toHaveCount(1);
|
||||
|
||||
await contains(".o_colorlist").click();
|
||||
expect("button.o_colorlist_toggler").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("open the list of colors if canToggle props is given", async function () {
|
||||
const selectedColorId = 0;
|
||||
await mountWithCleanup(Parent, {
|
||||
props: {
|
||||
canToggle: true,
|
||||
colors: [4, 5, 6],
|
||||
selectedColor: selectedColorId,
|
||||
onColorSelected: (colorId) => expect.step("color #" + colorId + " is selected"),
|
||||
},
|
||||
});
|
||||
expect(".o_colorlist").toHaveCount(1);
|
||||
expect(".o_colorlist button").toHaveClass("o_colorlist_item_color_" + selectedColorId);
|
||||
|
||||
await contains(".o_colorlist button").click();
|
||||
expect("button.o_colorlist_toggler").toHaveCount(0);
|
||||
expect(".o_colorlist button").toHaveCount(3);
|
||||
|
||||
await contains(".outsideDiv").click();
|
||||
expect(".o_colorlist button").toHaveCount(1);
|
||||
expect("button.o_colorlist_toggler").toHaveCount(1);
|
||||
|
||||
await contains(".o_colorlist_toggler").click();
|
||||
await contains(".o_colorlist button:eq(2)").click();
|
||||
expect.verifySteps(["color #6 is selected"]);
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import { test, expect } from "@odoo/hoot";
|
||||
import { click, edit } from "@odoo/hoot-dom";
|
||||
import { animationFrame, tick } from "@odoo/hoot-mock";
|
||||
import { Component, reactive, useState, xml } from "@odoo/owl";
|
||||
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { useDateTimePicker } from "@web/core/datetime/datetime_hook";
|
||||
import { DateTimeInput } from "@web/core/datetime/datetime_input";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
/**
|
||||
* @param {() => any} setup
|
||||
*/
|
||||
const mountInput = async (setup) => {
|
||||
await mountWithCleanup(Root, { props: { setup } });
|
||||
};
|
||||
|
||||
class Root extends Component {
|
||||
static components = { DateTimeInput };
|
||||
static template = xml`<input type="text" class="datetime_hook_input" t-ref="start-date"/>`;
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.props.setup();
|
||||
}
|
||||
}
|
||||
|
||||
test("reactivity: update inert object", async () => {
|
||||
const pickerProps = {
|
||||
value: false,
|
||||
type: "date",
|
||||
};
|
||||
|
||||
await mountInput(() => {
|
||||
useDateTimePicker({ pickerProps });
|
||||
});
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("");
|
||||
|
||||
pickerProps.value = DateTime.fromSQL("2023-06-06");
|
||||
await tick();
|
||||
|
||||
expect(".datetime_hook_input").toHaveText("");
|
||||
});
|
||||
|
||||
test("reactivity: useState & update getter object", async () => {
|
||||
const pickerProps = reactive({
|
||||
value: false,
|
||||
type: "date",
|
||||
});
|
||||
|
||||
await mountInput(() => {
|
||||
const state = useState(pickerProps);
|
||||
state.value; // artificially subscribe to value
|
||||
|
||||
useDateTimePicker({
|
||||
get pickerProps() {
|
||||
return pickerProps;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("");
|
||||
|
||||
pickerProps.value = DateTime.fromSQL("2023-06-06");
|
||||
await animationFrame();
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("06/06/2023");
|
||||
});
|
||||
|
||||
test("reactivity: update reactive object returned by the hook", async () => {
|
||||
let pickerProps;
|
||||
const defaultPickerProps = {
|
||||
value: false,
|
||||
type: "date",
|
||||
};
|
||||
|
||||
await mountInput(() => {
|
||||
pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }).state;
|
||||
});
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("");
|
||||
expect(pickerProps.value).toBe(false);
|
||||
|
||||
pickerProps.value = DateTime.fromSQL("2023-06-06");
|
||||
await tick();
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("06/06/2023");
|
||||
});
|
||||
|
||||
test("returned value is updated when input has changed", async () => {
|
||||
let pickerProps;
|
||||
const defaultPickerProps = {
|
||||
value: false,
|
||||
type: "date",
|
||||
};
|
||||
|
||||
await mountInput(() => {
|
||||
pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }).state;
|
||||
});
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("");
|
||||
expect(pickerProps.value).toBe(false);
|
||||
await click(".datetime_hook_input");
|
||||
await edit("06/06/2023");
|
||||
await click(document.body);
|
||||
|
||||
expect(pickerProps.value.toSQL().split(" ")[0]).toBe("2023-06-06");
|
||||
});
|
||||
|
||||
test("value is not updated if it did not change", async () => {
|
||||
const getShortDate = (date) => date.toSQL().split(" ")[0];
|
||||
|
||||
let pickerProps;
|
||||
const defaultPickerProps = {
|
||||
value: DateTime.fromSQL("2023-06-06"),
|
||||
type: "date",
|
||||
};
|
||||
|
||||
await mountInput(() => {
|
||||
pickerProps = useDateTimePicker({
|
||||
pickerProps: defaultPickerProps,
|
||||
onApply: (value) => {
|
||||
expect.step(getShortDate(value));
|
||||
},
|
||||
}).state;
|
||||
});
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("06/06/2023");
|
||||
expect(getShortDate(pickerProps.value)).toBe("2023-06-06");
|
||||
|
||||
await click(".datetime_hook_input");
|
||||
await edit("06/06/2023");
|
||||
await click(document.body);
|
||||
|
||||
expect(getShortDate(pickerProps.value)).toBe("2023-06-06");
|
||||
expect.verifySteps([]);
|
||||
|
||||
await click(".datetime_hook_input");
|
||||
await edit("07/07/2023");
|
||||
await click(document.body);
|
||||
|
||||
expect(getShortDate(pickerProps.value)).toBe("2023-07-07");
|
||||
expect.verifySteps(["2023-07-07"]);
|
||||
});
|
||||
|
||||
test("close popover when owner component is unmounted", async() => {
|
||||
class Child extends Component {
|
||||
static components = { DateTimeInput };
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<div>
|
||||
<input type="text" class="datetime_hook_input" t-ref="start-date"/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
useDateTimePicker({
|
||||
pickerProps: {
|
||||
value: [false, false],
|
||||
type: "date",
|
||||
range: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { resolve: hidePopover, promise } = Promise.withResolvers();
|
||||
|
||||
class DateTimeToggler extends Component {
|
||||
static components = { Child };
|
||||
static props = [];
|
||||
static template = xml`<Child t-if="!state.hidden"/>`;
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
hidden: false,
|
||||
});
|
||||
promise.then(() => {
|
||||
this.state.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(DateTimeToggler);
|
||||
|
||||
await click("input.datetime_hook_input");
|
||||
await animationFrame();
|
||||
expect(".o_datetime_picker").toHaveCount(1);
|
||||
|
||||
// we can't simply add a button because `useClickAway` will be triggered, thus closing the popover properly
|
||||
hidePopover();
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
expect(".o_datetime_picker").toHaveCount(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,577 @@
|
|||
import { test, expect, describe } from "@odoo/hoot";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { assertDateTimePicker, getPickerCell } from "../../datetime/datetime_test_helpers";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { DateTimeInput } from "@web/core/datetime/datetime_input";
|
||||
import {
|
||||
contains,
|
||||
defineParams,
|
||||
makeMockEnv,
|
||||
mountWithCleanup,
|
||||
serverState,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { click, edit, queryAll, queryFirst, select } from "@odoo/hoot-dom";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
class DateTimeInputComp extends Component {
|
||||
static components = { DateTimeInput };
|
||||
static template = xml`<DateTimeInput t-props="props" />`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
async function changeLang(lang) {
|
||||
serverState.lang = lang;
|
||||
await makeMockEnv();
|
||||
}
|
||||
|
||||
describe("DateTimeInput (date)", () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
date_format: "%d/%m/%Y",
|
||||
time_format: "%H:%M:%S",
|
||||
},
|
||||
});
|
||||
|
||||
test("basic rendering", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveCount(1);
|
||||
assertDateTimePicker(false);
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09/01/1997");
|
||||
|
||||
await click(".o_datetime_input");
|
||||
await animationFrame();
|
||||
|
||||
assertDateTimePicker({
|
||||
title: "January 1997",
|
||||
date: [
|
||||
{
|
||||
cells: [
|
||||
[0, 0, 0, 1, 2, 3, 4],
|
||||
[5, 6, 7, 8, [9], 10, 11],
|
||||
[12, 13, 14, 15, 16, 17, 18],
|
||||
[19, 20, 21, 22, 23, 24, 25],
|
||||
[26, 27, 28, 29, 30, 31, 0],
|
||||
],
|
||||
daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||
weekNumbers: [1, 2, 3, 4, 5],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("pick a date", async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
await makeMockEnv();
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
onChange: (date) => {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy")).toBe("08/02/1997", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await contains(".o_datetime_picker .o_next").click();
|
||||
|
||||
expect.verifySteps([]);
|
||||
await contains(getPickerCell("8")).click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("08/02/1997");
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
test("pick a date with FR locale", async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
await changeLang("fr-FR");
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
format: "dd MMM, yyyy",
|
||||
onChange: (date) => {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy")).toBe("19/09/1997", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 janv., 1997");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await contains(".o_zoom_out").click();
|
||||
await contains(getPickerCell("sept.")).click();
|
||||
await contains(getPickerCell("19")).click();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("19 sept., 1997");
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
test("pick a date with locale (locale with different symbols)", async () => {
|
||||
expect.assertions(5);
|
||||
|
||||
await changeLang("gu");
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
format: "dd MMM, yyyy",
|
||||
onChange: (date) => {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy")).toBe("19/09/1997", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 જાન્યુ, 1997");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 જાન્યુ, 1997");
|
||||
|
||||
await contains(".o_zoom_out").click();
|
||||
await contains(getPickerCell("સપ્ટે")).click();
|
||||
await contains(getPickerCell("19")).click();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("19 સપ્ટે, 1997");
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
test("enter a date value", async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
onChange: (date) => {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy")).toBe("08/02/1997", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await edit("08/02/1997");
|
||||
await animationFrame();
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
|
||||
await click(".o_datetime_input");
|
||||
await animationFrame();
|
||||
|
||||
expect(getPickerCell("8")).toHaveClass("o_selected");
|
||||
});
|
||||
|
||||
test("Date format is correctly set", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
format: "yyyy/MM/dd",
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("1997/01/09");
|
||||
|
||||
// Forces an update to assert that the registered format is the correct one
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("1997/01/09");
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("popover should have enough space to be displayed", async () => {
|
||||
class Root extends Component {
|
||||
static components = { DateTimeInput };
|
||||
static template = xml`<div class="d-flex"><DateTimeInput t-props="props" /></div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await mountWithCleanup(Root, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
},
|
||||
});
|
||||
const parent = queryFirst(".o_datetime_input").parentElement;
|
||||
const initialParentHeight = parent.clientHeight;
|
||||
|
||||
await contains(".o_datetime_input", { root: parent }).click();
|
||||
|
||||
const pickerRectHeight = queryFirst(".o_datetime_picker").clientHeight;
|
||||
|
||||
expect(initialParentHeight).toBeLessThan(pickerRectHeight, {
|
||||
message: "initial height shouldn't be big enough to display the picker",
|
||||
});
|
||||
expect(parent.clientHeight).toBeGreaterThan(pickerRectHeight, {
|
||||
message: "initial height should be big enough to display the picker",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DateTimeInput (datetime)", () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
date_format: "%d/%m/%Y",
|
||||
time_format: "%H:%M:%S",
|
||||
},
|
||||
});
|
||||
|
||||
test("basic rendering", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveCount(1);
|
||||
assertDateTimePicker(false);
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09/01/1997 12:30:01");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
assertDateTimePicker({
|
||||
title: "January 1997",
|
||||
date: [
|
||||
{
|
||||
cells: [
|
||||
[0, 0, 0, 1, 2, 3, 4],
|
||||
[5, 6, 7, 8, [9], 10, 11],
|
||||
[12, 13, 14, 15, 16, 17, 18],
|
||||
[19, 20, 21, 22, 23, 24, 25],
|
||||
[26, 27, 28, 29, 30, 31, 0],
|
||||
],
|
||||
daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||
weekNumbers: [1, 2, 3, 4, 5],
|
||||
},
|
||||
],
|
||||
time: [[12, 30]],
|
||||
});
|
||||
});
|
||||
|
||||
test("pick a date and time", async () => {
|
||||
await makeMockEnv();
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
onChange: (date) => expect.step(date.toSQL().split(".")[0]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09/01/1997 12:30:01");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
// Select February 8th
|
||||
await contains(".o_datetime_picker .o_next").click();
|
||||
await contains(getPickerCell("8")).click();
|
||||
|
||||
// Select 15:45
|
||||
const [hourSelect, minuteSelect] = queryAll(".o_time_picker_select");
|
||||
await select("15", { target: hourSelect });
|
||||
await animationFrame();
|
||||
await select("45", { target: minuteSelect });
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("08/02/1997 15:45:01");
|
||||
expect.verifySteps(["1997-02-08 12:30:01", "1997-02-08 15:30:01", "1997-02-08 15:45:01"]);
|
||||
});
|
||||
|
||||
test("pick a date and time with locale", async () => {
|
||||
await changeLang("fr_FR");
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
format: "dd MMM, yyyy HH:mm:ss",
|
||||
onChange: (date) => expect.step(date.toSQL().split(".")[0]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 janv., 1997 12:30:01");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
await contains(".o_zoom_out").click();
|
||||
await contains(getPickerCell("sept.")).click();
|
||||
await contains(getPickerCell("1")).click();
|
||||
|
||||
// Select 15:45
|
||||
const [hourSelect, minuteSelect] = queryAll(".o_time_picker_select");
|
||||
await select("15", { target: hourSelect });
|
||||
await animationFrame();
|
||||
await select("45", { target: minuteSelect });
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("01 sept., 1997 15:45:01");
|
||||
expect.verifySteps(["1997-09-01 12:30:01", "1997-09-01 15:30:01", "1997-09-01 15:45:01"]);
|
||||
});
|
||||
|
||||
test("pick a time with 12 hour format without meridiem", async () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
date_format: "%d/%m/%Y",
|
||||
time_format: "%I:%M:%S",
|
||||
},
|
||||
});
|
||||
|
||||
await makeMockEnv();
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 08:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
onChange: (date) => expect.step(date.toSQL().split(".")[0]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09/01/1997 08:30:01");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
const [, minuteSelect] = queryAll(".o_time_picker_select");
|
||||
await select("15", { target: minuteSelect });
|
||||
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["1997-01-09 08:15:01"]);
|
||||
});
|
||||
|
||||
test("enter a datetime value", async () => {
|
||||
expect.assertions(7);
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
onChange: (date) => {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy HH:mm:ss")).toBe("08/02/1997 15:45:05", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await edit("08/02/1997 15:45:05");
|
||||
await animationFrame();
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("08/02/1997 15:45:05");
|
||||
expect(getPickerCell("8")).toHaveClass("o_selected");
|
||||
|
||||
const [hourSelect, minuteSelect] = queryAll(".o_time_picker_select");
|
||||
expect(hourSelect).toHaveValue("15");
|
||||
expect(minuteSelect).toHaveValue("45");
|
||||
});
|
||||
|
||||
test("Date time format is correctly set", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
format: "HH:mm:ss yyyy/MM/dd",
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("12:30:01 1997/01/09");
|
||||
|
||||
// Forces an update to assert that the registered format is the correct one
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("12:30:01 1997/01/09");
|
||||
});
|
||||
|
||||
test("Datepicker works with norwegian locale", async () => {
|
||||
expect.assertions(7);
|
||||
|
||||
await changeLang("nb");
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/04/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
format: "dd MMM, yyyy",
|
||||
onChange(date) {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy")).toBe("01/04/1997", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 apr., 1997");
|
||||
|
||||
// Forces an update to assert that the registered format is the correct one
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 apr., 1997");
|
||||
|
||||
await contains(getPickerCell("1")).click();
|
||||
expect(".o_datetime_input").toHaveValue("01 apr., 1997");
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
|
||||
await click(".o_apply");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
test("Datepicker works with dots and commas in format", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("10/03/2023 13:14:27", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
format: "dd.MM,yyyy",
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("10.03,2023");
|
||||
|
||||
// Forces an update to assert that the registered format is the correct one
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("10.03,2023");
|
||||
});
|
||||
|
||||
test("start with no value", async () => {
|
||||
expect.assertions(5);
|
||||
|
||||
await makeMockEnv();
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
type: "datetime",
|
||||
onChange(date) {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy HH:mm:ss")).toBe("08/02/1997 15:45:05", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("");
|
||||
expect.verifySteps([]);
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await edit("08/02/1997 15:45:05");
|
||||
await animationFrame();
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
expect(".o_datetime_input").toHaveValue("08/02/1997 15:45:05");
|
||||
});
|
||||
|
||||
test("Clicking close button closes datetime picker", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
format: "dd MMM, yyyy HH:mm:ss",
|
||||
},
|
||||
});
|
||||
await contains(".o_datetime_input").click();
|
||||
await contains(".o_datetime_picker .o_datetime_buttons .btn-secondary").click();
|
||||
|
||||
expect(".o_datetime_picker").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("check datepicker in localization with textual month format", async () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
date_format: "%b/%d/%Y",
|
||||
time_format: "%H:%M:%S",
|
||||
},
|
||||
});
|
||||
|
||||
let onChangeDate;
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
onChange: (date) => (onChangeDate = date),
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("Jan/09/1997");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await contains(getPickerCell("5")).click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("Jan/05/1997");
|
||||
expect(onChangeDate.toFormat("dd/MM/yyyy")).toBe("05/01/1997");
|
||||
});
|
||||
|
||||
test("arab locale, latin numbering system as input", async () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
date_format: "%d %b, %Y",
|
||||
time_format: "%H:%M:%S",
|
||||
},
|
||||
});
|
||||
|
||||
await changeLang("ar-001");
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp);
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await edit("٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
|
||||
await animationFrame();
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await edit("15 07, 2020 12:30:43");
|
||||
await animationFrame();
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("١٥ يوليو, ٢٠٢٠ ١٢:٣٠:٤٣");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
import { expect, test, describe, destroy } from "@odoo/hoot";
|
||||
import { tick, Deferred } from "@odoo/hoot-mock";
|
||||
import { press } from "@odoo/hoot-dom";
|
||||
import { mountWithCleanup, contains, makeDialogMockEnv } from "@web/../tests/web_test_helpers";
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("check content confirmation dialog", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {},
|
||||
confirm: () => {},
|
||||
cancel: () => {},
|
||||
},
|
||||
});
|
||||
expect(".modal-header").toHaveText("Confirmation");
|
||||
expect(".modal-body").toHaveText("Some content");
|
||||
});
|
||||
|
||||
test("Without dismiss callback: pressing escape to close the dialog", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
expect.step("Cancel action");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
await press("escape");
|
||||
await tick();
|
||||
expect.verifySteps(["Cancel action", "Close action"]);
|
||||
});
|
||||
|
||||
test("With dismiss callback: pressing escape to close the dialog", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
dismiss: () => {
|
||||
expect.step("Dismiss action");
|
||||
},
|
||||
},
|
||||
});
|
||||
await press("escape");
|
||||
await tick();
|
||||
expect.verifySteps(["Dismiss action", "Close action"]);
|
||||
});
|
||||
|
||||
test("Without dismiss callback: clicking on 'X' to close the dialog", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
expect.step("Cancel action");
|
||||
},
|
||||
},
|
||||
});
|
||||
await contains(".modal-header .btn-close").click();
|
||||
expect.verifySteps(["Cancel action", "Close action"]);
|
||||
});
|
||||
|
||||
test("With dismiss callback: clicking on 'X' to close the dialog", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
dismiss: () => {
|
||||
expect.step("Dismiss action");
|
||||
},
|
||||
},
|
||||
});
|
||||
await contains(".modal-header .btn-close").click();
|
||||
expect.verifySteps(["Dismiss action", "Close action"]);
|
||||
});
|
||||
|
||||
test("clicking on 'Ok'", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
expect.step("Confirm action");
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
await contains(".modal-footer .btn-primary").click();
|
||||
expect.verifySteps(["Confirm action", "Close action"]);
|
||||
});
|
||||
|
||||
test("clicking on 'Cancel'", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
expect.step("Cancel action");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
await contains(".modal-footer .btn-secondary").click();
|
||||
expect.verifySteps(["Cancel action", "Close action"]);
|
||||
});
|
||||
|
||||
test("can't click twice on 'Ok'", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {},
|
||||
confirm: () => {
|
||||
expect.step("Confirm action");
|
||||
},
|
||||
cancel: () => {},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
expect(".modal-footer .btn-primary").not.toHaveAttribute("disabled");
|
||||
expect(".modal-footer .btn-secondary").not.toHaveAttribute("disabled");
|
||||
await contains(".modal-footer .btn-primary").click();
|
||||
expect(".modal-footer .btn-primary").toHaveAttribute("disabled");
|
||||
expect(".modal-footer .btn-secondary").toHaveAttribute("disabled");
|
||||
expect.verifySteps(["Confirm action"]);
|
||||
});
|
||||
|
||||
test("can't click twice on 'Cancel'", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {},
|
||||
confirm: () => {},
|
||||
cancel: () => {
|
||||
expect.step("Cancel action");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
expect(".modal-footer .btn-primary").not.toHaveAttribute("disabled");
|
||||
expect(".modal-footer .btn-secondary").not.toHaveAttribute("disabled");
|
||||
await contains(".modal-footer .btn-secondary").click();
|
||||
expect(".modal-footer .btn-primary").toHaveAttribute("disabled");
|
||||
expect(".modal-footer .btn-secondary").toHaveAttribute("disabled");
|
||||
expect.verifySteps(["Cancel action"]);
|
||||
});
|
||||
|
||||
test("can't cancel (with escape) after confirm", async () => {
|
||||
const def = new Deferred();
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
expect.step("Confirm action");
|
||||
return def;
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error("should not cancel");
|
||||
},
|
||||
},
|
||||
});
|
||||
await contains(".modal-footer .btn-primary").click();
|
||||
expect.verifySteps(["Confirm action"]);
|
||||
await press("escape");
|
||||
await tick();
|
||||
expect.verifySteps([]);
|
||||
def.resolve();
|
||||
await tick();
|
||||
expect.verifySteps(["Close action"]);
|
||||
});
|
||||
|
||||
test("wait for confirm callback before closing", async () => {
|
||||
const def = new Deferred();
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
expect.step("Confirm action");
|
||||
return def;
|
||||
},
|
||||
},
|
||||
});
|
||||
await contains(".modal-footer .btn-primary").click();
|
||||
expect.verifySteps(["Confirm action"]);
|
||||
def.resolve();
|
||||
await tick();
|
||||
expect.verifySteps(["Close action"]);
|
||||
});
|
||||
|
||||
test("Focus is correctly restored after confirmation", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`<div class="my-comp"><input type="text" class="my-input"/></div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent, { env });
|
||||
await contains(".my-input").focus();
|
||||
expect(".my-input").toBeFocused();
|
||||
|
||||
const dialog = await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
confirm: () => {},
|
||||
close: () => {},
|
||||
},
|
||||
});
|
||||
expect(".modal-footer .btn-primary").toBeFocused();
|
||||
await contains(".modal-footer .btn-primary").click();
|
||||
expect(document.body).toBeFocused();
|
||||
destroy(dialog);
|
||||
await Promise.resolve();
|
||||
expect(".my-input").toBeFocused();
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import { expect } from "@odoo/hoot";
|
||||
import {
|
||||
click,
|
||||
queryAll,
|
||||
queryAllTexts,
|
||||
queryAllValues,
|
||||
queryFirst,
|
||||
queryText,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
|
||||
const PICKER_COLS = 7;
|
||||
|
||||
/**
|
||||
* @typedef {import("@web/core/datetime/datetime_picker").DateTimePickerProps} DateTimePickerProps
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {false | {
|
||||
* title?: string | string[],
|
||||
* date?: {
|
||||
* cells: (number | string | [number] | [string])[][],
|
||||
* daysOfWeek?: string[],
|
||||
* weekNumbers?: number[],
|
||||
* }[],
|
||||
* time?: ([number, number] | [number, number, "AM" | "PM"])[],
|
||||
* }} expectedParams
|
||||
*/
|
||||
export function assertDateTimePicker(expectedParams) {
|
||||
// Check for picker in DOM
|
||||
if (expectedParams) {
|
||||
expect(".o_datetime_picker").toHaveCount(1);
|
||||
} else {
|
||||
expect(".o_datetime_picker").toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, date, time } = expectedParams;
|
||||
|
||||
// Title
|
||||
if (title) {
|
||||
expect(".o_datetime_picker_header").toHaveCount(1);
|
||||
expect(".o_datetime_picker_header").toHaveText(title);
|
||||
} else {
|
||||
expect(".o_datetime_picker_header").toHaveCount(0);
|
||||
}
|
||||
|
||||
// Time picker
|
||||
if (time) {
|
||||
expect(".o_time_picker").toHaveCount(time.length);
|
||||
for (let i = 0; i < time.length; i++) {
|
||||
const expectedTime = time[i];
|
||||
const values = queryAll(`.o_time_picker:nth-child(${i + 1}) .o_time_picker_select`).map(
|
||||
(sel) => sel.value
|
||||
);
|
||||
const actual = [...values.slice(0, 2).map(Number), ...values.slice(2)];
|
||||
expect(actual).toEqual(expectedTime, {
|
||||
message: `time values should be [${expectedTime}]`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
expect(".o_time_picker").toHaveCount(0);
|
||||
}
|
||||
|
||||
// Date picker
|
||||
expect(".o_date_picker").toHaveCount(date.length);
|
||||
|
||||
let selectedCells = 0;
|
||||
let invalidCells = 0;
|
||||
let outOfRangeCells = 0;
|
||||
let todayCells = 0;
|
||||
for (let i = 0; i < date.length; i++) {
|
||||
const { cells, daysOfWeek, weekNumbers } = date[i];
|
||||
const cellEls = queryAll(`.o_date_picker:nth-child(${i + 1}) .o_date_item_cell`);
|
||||
const pickerRows = cells.length;
|
||||
expect(cellEls.length).toBe(pickerRows * PICKER_COLS, {
|
||||
message: `picker should have ${
|
||||
pickerRows * PICKER_COLS
|
||||
} cells (${pickerRows} rows and ${PICKER_COLS} columns)`,
|
||||
});
|
||||
|
||||
if (daysOfWeek) {
|
||||
const actualDow = queryAllTexts(
|
||||
`.o_date_picker:nth-child(${i + 1}) .o_day_of_week_cell`
|
||||
);
|
||||
expect(actualDow).toEqual(daysOfWeek, {
|
||||
message: `picker should display the days of week: ${daysOfWeek
|
||||
.map((dow) => `"${dow}"`)
|
||||
.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (weekNumbers) {
|
||||
expect(
|
||||
queryAllTexts(`.o_date_picker:nth-child(${i + 1}) .o_week_number_cell`).map(Number)
|
||||
).toEqual(weekNumbers, {
|
||||
message: `picker should display the week numbers (${weekNumbers.join(", ")})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Date cells
|
||||
const expectedCells = cells.flatMap((row, rowIndex) =>
|
||||
row.map((cell, colIndex) => {
|
||||
const cellEl = cellEls[rowIndex * PICKER_COLS + colIndex];
|
||||
let value = cell;
|
||||
if (Array.isArray(cell)) {
|
||||
// Selected
|
||||
value = value[0];
|
||||
selectedCells++;
|
||||
expect(cellEl).toHaveClass("o_selected");
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
// Today
|
||||
value = Number(value);
|
||||
todayCells++;
|
||||
expect(cellEl).toHaveClass("o_today");
|
||||
}
|
||||
if (value === 0) {
|
||||
// Out of range
|
||||
value = "";
|
||||
outOfRangeCells++;
|
||||
expect(cellEl).toHaveClass("o_out_of_range");
|
||||
} else if (value < 0) {
|
||||
// Invalid
|
||||
value = Math.abs(value);
|
||||
invalidCells++;
|
||||
expect(cellEl).toHaveAttribute("disabled");
|
||||
}
|
||||
return String(value);
|
||||
})
|
||||
);
|
||||
|
||||
expect(cellEls.map((cell) => queryText(cell))).toEqual(expectedCells, {
|
||||
message: `cell content should match the expected values: [${expectedCells.join(", ")}]`,
|
||||
});
|
||||
}
|
||||
|
||||
expect(".o_selected").toHaveCount(selectedCells);
|
||||
expect(".o_datetime_button[disabled]").toHaveCount(invalidCells);
|
||||
expect(".o_out_of_range").toHaveCount(outOfRangeCells);
|
||||
expect(".o_today").toHaveCount(todayCells);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RegExp | string} expr
|
||||
* @param {boolean} [inBounds=false]
|
||||
*/
|
||||
export function getPickerCell(expr, inBounds = false) {
|
||||
const cells = queryAll(
|
||||
`.o_datetime_picker .o_date_item_cell${
|
||||
inBounds ? ":not(.o_out_of_range)" : ""
|
||||
}:contains("/^${expr}$/")`
|
||||
);
|
||||
return cells.length === 1 ? cells[0] : cells;
|
||||
}
|
||||
|
||||
export function getPickerApplyButton() {
|
||||
return queryFirst(".o_datetime_picker .o_datetime_buttons .o_apply");
|
||||
}
|
||||
|
||||
export async function zoomOut() {
|
||||
click(".o_zoom_out");
|
||||
await animationFrame();
|
||||
}
|
||||
|
||||
export function getTimePickers({ parse = false } = {}) {
|
||||
return queryAll(".o_time_picker").map((timePickerEl) => {
|
||||
if (parse) {
|
||||
return queryAllValues(".o_time_picker_select", { root: timePickerEl });
|
||||
}
|
||||
return queryAll(".o_time_picker_select", { root: timePickerEl });
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,804 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
click,
|
||||
queryAll,
|
||||
queryAllProperties,
|
||||
queryAllTexts,
|
||||
queryOne,
|
||||
queryText,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import {
|
||||
clearRegistry,
|
||||
contains,
|
||||
defineModels,
|
||||
defineWebModels,
|
||||
fields,
|
||||
getService,
|
||||
makeDialogMockEnv,
|
||||
models,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
serverState,
|
||||
webModels,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { useDebugCategory, useOwnDebugContext } from "@web/core/debug/debug_context";
|
||||
import { DebugMenu } from "@web/core/debug/debug_menu";
|
||||
import { becomeSuperuser, regenerateAssets } from "@web/core/debug/debug_menu_items";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { user } from "@web/core/user";
|
||||
import { ActionDialog } from "@web/webclient/actions/action_dialog";
|
||||
import { openViewItem } from "@web/webclient/debug/debug_items";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
|
||||
class DebugMenuParent extends Component {
|
||||
static template = xml`<DebugMenu/>`;
|
||||
static components = { DebugMenu };
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
useOwnDebugContext({ categories: ["default", "custom"] });
|
||||
}
|
||||
}
|
||||
|
||||
const debugRegistry = registry.category("debug");
|
||||
|
||||
onRpc(async (args) => {
|
||||
if (args.method === "has_access") {
|
||||
return true;
|
||||
}
|
||||
if (args.route === "/web/dataset/call_kw/ir.attachment/regenerate_assets_bundles") {
|
||||
expect.step("ir.attachment/regenerate_assets_bundles");
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Remove this service to clear the debug menu from anything else than what the test insert into
|
||||
registry.category("services").remove("profiling");
|
||||
clearRegistry(debugRegistry.category("default"));
|
||||
clearRegistry(debugRegistry.category("custom"));
|
||||
});
|
||||
|
||||
describe.tags("desktop");
|
||||
describe("DebugMenu", () => {
|
||||
test("can be rendered", async () => {
|
||||
debugRegistry
|
||||
.category("default")
|
||||
.add("item_1", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 1",
|
||||
callback: () => {
|
||||
expect.step("callback item_1");
|
||||
},
|
||||
sequence: 10,
|
||||
section: "a",
|
||||
};
|
||||
})
|
||||
.add("item_2", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 2",
|
||||
callback: () => {
|
||||
expect.step("callback item_2");
|
||||
},
|
||||
sequence: 5,
|
||||
section: "a",
|
||||
};
|
||||
})
|
||||
.add("item_3", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 3",
|
||||
callback: () => {
|
||||
expect.step("callback item_3");
|
||||
},
|
||||
section: "b",
|
||||
};
|
||||
})
|
||||
.add("item_4", () => {
|
||||
return null;
|
||||
});
|
||||
await mountWithCleanup(DebugMenuParent);
|
||||
await contains("button.dropdown-toggle").click();
|
||||
expect(".dropdown-menu .dropdown-item").toHaveCount(3);
|
||||
expect(".dropdown-menu .dropdown-menu_group").toHaveCount(2);
|
||||
const children = [...queryOne(".dropdown-menu").children] || [];
|
||||
expect(children.map((el) => el.tagName)).toEqual(["DIV", "SPAN", "SPAN", "DIV", "SPAN"]);
|
||||
expect(queryAllTexts(children)).toEqual(["a", "Item 2", "Item 1", "b", "Item 3"]);
|
||||
|
||||
for (const item of queryAll(".dropdown-menu .dropdown-item")) {
|
||||
await click(item);
|
||||
}
|
||||
|
||||
expect.verifySteps(["callback item_2", "callback item_1", "callback item_3"]);
|
||||
});
|
||||
|
||||
test("items are sorted by sequence regardless of category", async () => {
|
||||
debugRegistry
|
||||
.category("default")
|
||||
.add("item_1", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 4",
|
||||
sequence: 4,
|
||||
};
|
||||
})
|
||||
.add("item_2", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 1",
|
||||
sequence: 1,
|
||||
};
|
||||
});
|
||||
debugRegistry
|
||||
.category("custom")
|
||||
.add("item_1", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 3",
|
||||
sequence: 3,
|
||||
};
|
||||
})
|
||||
.add("item_2", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 2",
|
||||
sequence: 2,
|
||||
};
|
||||
});
|
||||
await mountWithCleanup(DebugMenuParent);
|
||||
await contains("button.dropdown-toggle").click();
|
||||
expect(queryAllTexts(".dropdown-menu .dropdown-item")).toEqual([
|
||||
"Item 1",
|
||||
"Item 2",
|
||||
"Item 3",
|
||||
"Item 4",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Don't display the DebugMenu if debug mode is disabled", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ActionDialog, {
|
||||
env,
|
||||
props: { close: () => {} },
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog .o_debug_manager .fa-bug").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Display the DebugMenu correctly in a ActionDialog if debug mode is enabled", async () => {
|
||||
debugRegistry.category("default").add("global", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Global 1",
|
||||
callback: () => {
|
||||
expect.step("callback global_1");
|
||||
},
|
||||
sequence: 0,
|
||||
};
|
||||
});
|
||||
debugRegistry
|
||||
.category("custom")
|
||||
.add("item1", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 1",
|
||||
callback: () => {
|
||||
expect.step("callback item_1");
|
||||
},
|
||||
sequence: 10,
|
||||
};
|
||||
})
|
||||
.add("item2", ({ customKey }) => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 2",
|
||||
callback: () => {
|
||||
expect.step("callback item_2");
|
||||
expect(customKey).toBe("abc");
|
||||
},
|
||||
sequence: 20,
|
||||
};
|
||||
});
|
||||
class WithCustom extends ActionDialog {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
useDebugCategory("custom", { customKey: "abc" });
|
||||
}
|
||||
}
|
||||
serverState.debug = "1";
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(WithCustom, {
|
||||
env,
|
||||
props: { close: () => {} },
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog .o_debug_manager .fa-bug").toHaveCount(1);
|
||||
await contains(".o_dialog .o_debug_manager button").click();
|
||||
expect(".dropdown-menu .dropdown-item").toHaveCount(2);
|
||||
// Check that global debugManager elements are not displayed (global_1)
|
||||
const items = queryAll(".dropdown-menu .dropdown-item");
|
||||
expect(queryAllTexts(items)).toEqual(["Item 1", "Item 2"]);
|
||||
for (const item of items) {
|
||||
await click(item);
|
||||
}
|
||||
expect.verifySteps(["callback item_1", "callback item_2"]);
|
||||
});
|
||||
|
||||
test("can regenerate assets bundles", async () => {
|
||||
patchWithCleanup(browser.location, {
|
||||
reload: () => expect.step("reloadPage"),
|
||||
});
|
||||
debugRegistry.category("default").add("regenerateAssets", regenerateAssets);
|
||||
await mountWithCleanup(DebugMenuParent);
|
||||
await contains("button.dropdown-toggle").click();
|
||||
expect(".dropdown-menu .dropdown-item").toHaveCount(1);
|
||||
const item = queryOne(".dropdown-menu .dropdown-item");
|
||||
expect(item).toHaveText("Regenerate Assets");
|
||||
await click(item);
|
||||
await animationFrame();
|
||||
expect.verifySteps(["ir.attachment/regenerate_assets_bundles", "reloadPage"]);
|
||||
});
|
||||
|
||||
test("cannot acess the Become superuser menu if not admin", async () => {
|
||||
debugRegistry.category("default").add("becomeSuperuser", becomeSuperuser);
|
||||
user.isAdmin = false;
|
||||
await mountWithCleanup(DebugMenuParent);
|
||||
await contains("button.dropdown-toggle").click();
|
||||
expect(".dropdown-menu .dropdown-item").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("can open a view", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.IrUiView._views.list = `<list><field name="name"/><field name="type"/></list>`;
|
||||
webModels.ResPartner._views["form,1"] = `<form><div class="some_view"/></form>`;
|
||||
|
||||
webModels.IrUiView._records.push({
|
||||
id: 1,
|
||||
name: "formView",
|
||||
model: "res.partner",
|
||||
type: "form",
|
||||
active: true,
|
||||
});
|
||||
|
||||
defineWebModels();
|
||||
registry.category("debug").category("default").add("openViewItem", openViewItem);
|
||||
|
||||
await mountWithCleanup(WebClient);
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item").click();
|
||||
expect(".modal .o_list_view").toHaveCount(1);
|
||||
await contains(".modal .o_list_view .o_data_row td").click();
|
||||
expect(".modal").toHaveCount(0);
|
||||
expect(".some_view").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("get view: basic rendering", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views.list = `<list><field name="name"/></list>`;
|
||||
|
||||
defineWebModels();
|
||||
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "list"]],
|
||||
});
|
||||
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Computed Arch')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
expect(".modal-body").toHaveText(`<list><field name="name"/></list>`);
|
||||
});
|
||||
|
||||
test("can edit a pivot view", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views["pivot,18"] = "<pivot></pivot>";
|
||||
webModels.IrUiView._records.push({ id: 18, name: "Edit View" });
|
||||
webModels.IrUiView._views.form = `<form><field name="id"/></form>`;
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "pivot"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('View: Pivot')").click();
|
||||
|
||||
expect(".breadcrumb-item").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("Edit View");
|
||||
expect(".o_field_widget[name=id]").toHaveText("18");
|
||||
await click(".breadcrumb .o_back_button");
|
||||
await animationFrame();
|
||||
expect(".o_breadcrumb .active").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("Partners");
|
||||
});
|
||||
|
||||
test("can edit a search view", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views.list = `<list><field name="id"/></list>`;
|
||||
webModels.ResPartner._views["search,293"] = "<search></search>";
|
||||
webModels.IrUiView._records.push({ id: 293, name: "Edit View" });
|
||||
webModels.IrUiView._views.form = `<form><field name="id"/></form>`;
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
search_view_id: [293, "some_search_view"],
|
||||
views: [[false, "list"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('SearchView')").click();
|
||||
expect(".breadcrumb-item").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("Edit View");
|
||||
expect(".o_field_widget[name=id]").toHaveText("293");
|
||||
});
|
||||
|
||||
test("edit search view on action without search_view_id", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views.list = `<list><field name="id"/></list>`;
|
||||
webModels.ResPartner._views["search,293"] = "<search></search>";
|
||||
webModels.IrUiView._records.push({ id: 293, name: "Edit View" });
|
||||
webModels.IrUiView._views.form = `<form><field name="id"/></form>`;
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
search_view_id: false,
|
||||
views: [[false, "list"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('SearchView')").click();
|
||||
expect(".breadcrumb-item").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("Edit View");
|
||||
expect(".o_field_widget[name=id]").toHaveText("293");
|
||||
});
|
||||
|
||||
test("cannot edit the control panel of a form view contained in a dialog without control panel.", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views.form = `<form><field name="id"/></form>`;
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Create a Partner",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
target: "new",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
|
||||
await contains(".o_dialog .o_debug_manager button").click();
|
||||
expect(".dropdown-menu .dropdown-item:contains('SearchView')").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("set defaults: basic rendering", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views["form,24"] = `
|
||||
<form>
|
||||
<field name="name"/>
|
||||
</form>`;
|
||||
webModels.ResPartner._records.push({ id: 1000, name: "p1" });
|
||||
webModels.IrUiView._records.push({ id: 24 });
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
res_id: 1000,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[24, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
expect(".modal select#formview_default_fields").toHaveCount(1);
|
||||
expect(".modal #formview_default_fields option").toHaveCount(2);
|
||||
expect(".modal #formview_default_fields option").toHaveCount(2);
|
||||
expect(".modal #formview_default_fields option:nth-child(1)").toHaveText("");
|
||||
expect(".modal #formview_default_fields option:nth-child(2)").toHaveText("Name = p1");
|
||||
});
|
||||
|
||||
test("set defaults: click close", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
onRpc("ir.default", "set", async () => {
|
||||
throw new Error("should not create a default");
|
||||
});
|
||||
|
||||
webModels.ResPartner._views["form,25"] = `
|
||||
<form>
|
||||
<field name="name"/>
|
||||
</form>`;
|
||||
webModels.ResPartner._records.push({ id: 1001, name: "p1" });
|
||||
webModels.IrUiView._records.push({ id: 25 });
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
res_id: 1001,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[25, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
await contains(".modal .modal-footer button").click();
|
||||
expect(".modal").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("set defaults: select and save", async () => {
|
||||
expect.assertions(3);
|
||||
serverState.debug = "1";
|
||||
|
||||
onRpc("ir.default", "set", async (args) => {
|
||||
expect(args.args).toEqual(["res.partner", "name", "p1", true, true, false]);
|
||||
return true;
|
||||
});
|
||||
|
||||
webModels.ResPartner._views["form,26"] = `
|
||||
<form>
|
||||
<field name="name"/>
|
||||
</form>`;
|
||||
webModels.ResPartner._records.push({ id: 1002, name: "p1" });
|
||||
webModels.IrUiView._records.push({ id: 26 });
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
res_id: 1002,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[26, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
|
||||
await contains(".modal #formview_default_fields").select("name");
|
||||
await contains(".modal .modal-footer button:nth-child(2)").click();
|
||||
expect(".modal").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("fetch raw data: basic rendering", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
class Custom extends models.Model {
|
||||
_name = "custom";
|
||||
|
||||
name = fields.Char();
|
||||
raw = fields.Binary();
|
||||
properties = fields.Properties({
|
||||
string: "Properties",
|
||||
definition_record: "product_id",
|
||||
definition_record_field: "definitions",
|
||||
});
|
||||
definitions = fields.PropertiesDefinition({
|
||||
string: "Definitions",
|
||||
});
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
name: "custom1",
|
||||
raw: "<raw>",
|
||||
properties: [
|
||||
{
|
||||
name: "bd6404492c244cff",
|
||||
string: "test",
|
||||
type: "char",
|
||||
},
|
||||
],
|
||||
definitions: [{ name: "xphone_prop_1", string: "P1", type: "boolean" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
defineWebModels();
|
||||
defineModels([Custom]);
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Custom",
|
||||
res_model: "custom",
|
||||
res_id: 1,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains(/^Data/)").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
const data = queryText(".modal-body pre");
|
||||
const modalObj = JSON.parse(data);
|
||||
expect(modalObj).toInclude("create_date");
|
||||
expect(modalObj).toInclude("write_date");
|
||||
expect(modalObj).not.toInclude("raw");
|
||||
const expectedObj = {
|
||||
display_name: "custom1",
|
||||
id: 1,
|
||||
name: "custom1",
|
||||
properties: false,
|
||||
definitions: [
|
||||
{
|
||||
name: "xphone_prop_1",
|
||||
string: "P1",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(modalObj).toMatchObject(expectedObj);
|
||||
});
|
||||
|
||||
test("view metadata: basic rendering", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
onRpc("get_metadata", async () => {
|
||||
return [
|
||||
{
|
||||
create_date: "2023-01-26 14:12:10",
|
||||
create_uid: [4, "Some user"],
|
||||
id: 1003,
|
||||
noupdate: false,
|
||||
write_date: "2023-01-26 14:13:31",
|
||||
write_uid: [6, "Another User"],
|
||||
xmlid: "abc.partner_16",
|
||||
xmlids: [{ xmlid: "abc.partner_16", noupdate: false }],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
webModels.ResPartner._records.push({ id: 1003, name: "p1" });
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partner",
|
||||
res_model: "res.partner",
|
||||
res_id: 1003,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Metadata')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
const contentModal = queryAll(".modal-body table tr th, .modal-body table tr td");
|
||||
expect(queryAllTexts(contentModal)).toEqual([
|
||||
"ID:",
|
||||
"1003",
|
||||
"XML ID:",
|
||||
"abc.partner_16",
|
||||
"No Update:",
|
||||
"false (change)",
|
||||
"Creation User:",
|
||||
"Some user",
|
||||
"Creation Date:",
|
||||
"01/26/2023 15:12:10",
|
||||
"Latest Modification by:",
|
||||
"Another User",
|
||||
"Latest Modification Date:",
|
||||
"01/26/2023 15:13:31",
|
||||
]);
|
||||
});
|
||||
|
||||
test("set defaults: setting default value for datetime field", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
const argSteps = [];
|
||||
|
||||
onRpc("ir.default", "set", async (args) => {
|
||||
argSteps.push(args.args);
|
||||
return true;
|
||||
});
|
||||
|
||||
class Partner extends models.Model {
|
||||
_name = "partner";
|
||||
|
||||
datetime = fields.Datetime();
|
||||
reference = fields.Reference({ selection: [["pony", "Pony"]] });
|
||||
m2o = fields.Many2one({ relation: "pony" });
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "p1",
|
||||
datetime: "2024-01-24 16:46:16",
|
||||
reference: "pony,1",
|
||||
m2o: 1,
|
||||
},
|
||||
];
|
||||
|
||||
_views = {
|
||||
"form,18": /* xml */ `
|
||||
<form>
|
||||
<field name="datetime"/>
|
||||
<field name="reference"/>
|
||||
<field name="m2o"/>
|
||||
</form>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
class Pony extends models.Model {
|
||||
_name = "pony";
|
||||
|
||||
_records = [{ id: 1 }];
|
||||
}
|
||||
|
||||
class IrUiView extends models.Model {
|
||||
_name = "ir.ui.view";
|
||||
|
||||
name = fields.Char();
|
||||
model = fields.Char();
|
||||
|
||||
_records = [{ id: 18 }];
|
||||
}
|
||||
|
||||
defineModels([Partner, Pony, IrUiView]);
|
||||
await mountWithCleanup(WebClient);
|
||||
|
||||
for (const field_name of ["datetime", "reference", "m2o"]) {
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
res_id: 1,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[18, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
|
||||
await contains(".modal #formview_default_fields").select(field_name);
|
||||
await contains(".modal .modal-footer button:nth-child(2)").click();
|
||||
expect(".modal").toHaveCount(0);
|
||||
}
|
||||
|
||||
expect(argSteps).toEqual([
|
||||
["partner", "datetime", "2024-01-24 16:46:16", true, true, false],
|
||||
[
|
||||
"partner",
|
||||
"reference",
|
||||
{ resId: 1, resModel: "pony", displayName: "pony,1" },
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
],
|
||||
["partner", "m2o", 1, true, true, false],
|
||||
]);
|
||||
});
|
||||
|
||||
test("display model view in developer tools", async () => {
|
||||
serverState.debug = "1";
|
||||
webModels.ResPartner._views.form = `<form><field name="name"/></form>`;
|
||||
webModels.ResPartner._records.push({ id: 88, name: "p1" });
|
||||
webModels.IrModel._views.form = `
|
||||
<form>
|
||||
<field name="name"/>
|
||||
<field name="model"/>
|
||||
</form>`;
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Model:')").click();
|
||||
|
||||
expect(".breadcrumb-item").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("Partner");
|
||||
});
|
||||
|
||||
test("set defaults: settings default value with a very long value", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
const fooValue = "12".repeat(250);
|
||||
const argSteps = [];
|
||||
|
||||
onRpc("ir.default", "set", async (args) => {
|
||||
argSteps.push(args.args);
|
||||
return true;
|
||||
});
|
||||
|
||||
class Partner extends models.Model {
|
||||
_name = "partner";
|
||||
|
||||
foo = fields.Char();
|
||||
description = fields.Html();
|
||||
bar = fields.Many2one({ relation: "ir.ui.view" });
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "p1",
|
||||
foo: fooValue,
|
||||
description: fooValue,
|
||||
bar: 18,
|
||||
},
|
||||
];
|
||||
|
||||
_views = {
|
||||
form: `
|
||||
<form>
|
||||
<field name="foo"/>
|
||||
<field name="description"/>
|
||||
<field name="bar" invisible="1"/>
|
||||
</form>`,
|
||||
};
|
||||
}
|
||||
|
||||
class IrUiView extends models.Model {
|
||||
_name = "ir.ui.view";
|
||||
|
||||
name = fields.Char();
|
||||
model = fields.Char();
|
||||
|
||||
_records = [{ id: 18 }];
|
||||
}
|
||||
|
||||
defineModels([Partner, IrUiView]);
|
||||
|
||||
await mountWithCleanup(WebClient);
|
||||
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
res_id: 1,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
|
||||
expect(queryAllTexts`.modal #formview_default_fields option`).toEqual([
|
||||
"",
|
||||
"Foo = 121212121212121212121212121212121212121212121212121212121...",
|
||||
"Description = 121212121212121212121212121212121212121212121212121212121...",
|
||||
]);
|
||||
|
||||
expect(queryAllProperties(".modal #formview_default_fields option", "value")).toEqual([
|
||||
"",
|
||||
"foo",
|
||||
"description",
|
||||
]);
|
||||
|
||||
await contains(".modal #formview_default_fields").select("foo");
|
||||
await contains(".modal .modal-footer button:nth-child(2)").click();
|
||||
expect(".modal").toHaveCount(0);
|
||||
expect(argSteps).toEqual([["partner", "foo", fooValue, true, true, false]]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
427
odoo-bringout-oca-ocb-web/web/static/tests/core/dialog.test.js
Normal file
427
odoo-bringout-oca-ocb-web/web/static/tests/core/dialog.test.js
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import { destroy, expect, test } from "@odoo/hoot";
|
||||
import { keyDown, keyUp, press, queryAllTexts, queryOne, resize } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, onMounted, useState, xml } from "@odoo/owl";
|
||||
import {
|
||||
contains,
|
||||
getService,
|
||||
makeDialogMockEnv,
|
||||
mountWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
test("simple rendering", async () => {
|
||||
expect.assertions(8);
|
||||
class Parent extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`
|
||||
<Dialog title="'Wow(l) Effect'">
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog header .modal-title").toHaveCount(1, {
|
||||
message: "the header is rendered by default",
|
||||
});
|
||||
expect("header .modal-title").toHaveText("Wow(l) Effect");
|
||||
expect(".o_dialog main").toHaveCount(1, { message: "a dialog has always a main node" });
|
||||
expect("main").toHaveText("Hello!");
|
||||
expect(".o_dialog footer").toHaveCount(1, { message: "the footer is rendered by default" });
|
||||
expect(".o_dialog footer button").toHaveCount(1, {
|
||||
message: "the footer is rendered with a single button 'Ok' by default",
|
||||
});
|
||||
expect("footer button").toHaveText("Ok");
|
||||
});
|
||||
|
||||
test("hotkeys work on dialogs", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`
|
||||
<Dialog title="'Wow(l) Effect'">
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await makeDialogMockEnv({
|
||||
dialogData: {
|
||||
close: () => expect.step("close"),
|
||||
dismiss: () => expect.step("dismiss"),
|
||||
},
|
||||
});
|
||||
await mountWithCleanup(Parent);
|
||||
expect("header .modal-title").toHaveText("Wow(l) Effect");
|
||||
expect("footer button").toHaveText("Ok");
|
||||
// Same effect as clicking on the x button
|
||||
await press("escape");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["dismiss", "close"]);
|
||||
// Same effect as clicking on the Ok button
|
||||
await keyDown("control+enter");
|
||||
await keyUp("ctrl+enter");
|
||||
expect.verifySteps(["close"]);
|
||||
});
|
||||
|
||||
test("simple rendering with two dialogs", async () => {
|
||||
expect.assertions(3);
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<div>
|
||||
<Dialog title="'First Title'">
|
||||
Hello!
|
||||
</Dialog>
|
||||
<Dialog title="'Second Title'">
|
||||
Hello again!
|
||||
</Dialog>
|
||||
</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(2);
|
||||
expect(queryAllTexts("header .modal-title")).toEqual(["First Title", "Second Title"]);
|
||||
expect(queryAllTexts(".o_dialog .modal-body")).toEqual(["Hello!", "Hello again!"]);
|
||||
});
|
||||
|
||||
test("click on the button x triggers the service close", async () => {
|
||||
expect.assertions(2);
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv({
|
||||
dialogData: {
|
||||
close: () => expect.step("close"),
|
||||
dismiss: () => expect.step("dismiss"),
|
||||
},
|
||||
});
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
await contains(".o_dialog header button[aria-label='Close']").click();
|
||||
expect.verifySteps(["dismiss", "close"]);
|
||||
});
|
||||
|
||||
test("click on the button x triggers the close and dismiss defined by a Child component", async () => {
|
||||
expect.assertions(2);
|
||||
class Child extends Component {
|
||||
static template = xml`<div>Hello</div>`;
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.env.dialogData.close = () => expect.step("close");
|
||||
this.env.dialogData.dismiss = () => expect.step("dismiss");
|
||||
this.env.dialogData.scrollToOrigin = () => {};
|
||||
}
|
||||
}
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
<Child/>
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Child, Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
|
||||
await contains(".o_dialog header button[aria-label='Close']").click();
|
||||
expect.verifySteps(["dismiss", "close"]);
|
||||
});
|
||||
|
||||
test("click on the default footer button triggers the service close", async () => {
|
||||
expect.assertions(2);
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv({
|
||||
dialogData: {
|
||||
close: () => expect.step("close"),
|
||||
dismiss: () => expect.step("dismiss"),
|
||||
},
|
||||
});
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
|
||||
await contains(".o_dialog footer button").click();
|
||||
expect.verifySteps(["close"]);
|
||||
});
|
||||
|
||||
test("render custom footer buttons is possible", async () => {
|
||||
expect.assertions(2);
|
||||
class SimpleButtonsDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
content
|
||||
<t t-set-slot="footer">
|
||||
<div>
|
||||
<button class="btn btn-primary">The First Button</button>
|
||||
<button class="btn btn-primary">The Second Button</button>
|
||||
</div>
|
||||
</t>
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<div>
|
||||
<SimpleButtonsDialog/>
|
||||
</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { SimpleButtonsDialog };
|
||||
setup() {
|
||||
super.setup();
|
||||
this.state = useState({
|
||||
displayDialog: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog footer button").toHaveCount(2);
|
||||
});
|
||||
|
||||
test("embed an arbitrary component in a dialog is possible", async () => {
|
||||
expect.assertions(4);
|
||||
class SubComponent extends Component {
|
||||
static template = xml`
|
||||
<div class="o_subcomponent" t-esc="props.text" t-on-click="_onClick"/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
_onClick() {
|
||||
expect.step("subcomponent-clicked");
|
||||
this.props.onClicked();
|
||||
}
|
||||
}
|
||||
class Parent extends Component {
|
||||
static components = { Dialog, SubComponent };
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
<SubComponent text="'Wow(l) Effect'" onClicked="_onSubcomponentClicked"/>
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
_onSubcomponentClicked() {
|
||||
expect.step("message received by parent");
|
||||
}
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog main .o_subcomponent").toHaveCount(1);
|
||||
expect(".o_subcomponent").toHaveText("Wow(l) Effect");
|
||||
await contains(".o_subcomponent").click();
|
||||
expect.verifySteps(["subcomponent-clicked", "message received by parent"]);
|
||||
});
|
||||
|
||||
test("dialog without header/footer", async () => {
|
||||
expect.assertions(4);
|
||||
class Parent extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`
|
||||
<Dialog header="false" footer="false">content</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog header").toHaveCount(0);
|
||||
expect("main").toHaveCount(1, { message: "a dialog has always a main node" });
|
||||
expect(".o_dialog footer").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dialog size can be chosen", async () => {
|
||||
expect.assertions(5);
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<div>
|
||||
<Dialog contentClass="'xl'" size="'xl'">content</Dialog>
|
||||
<Dialog contentClass="'lg'">content</Dialog>
|
||||
<Dialog contentClass="'md'" size="'md'">content</Dialog>
|
||||
<Dialog contentClass="'sm'" size="'sm'">content</Dialog>
|
||||
</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(4);
|
||||
expect(".o_dialog .modal-dialog.modal-xl .xl").toHaveCount(1);
|
||||
expect(".o_dialog .modal-dialog.modal-lg .lg").toHaveCount(1);
|
||||
expect(".o_dialog .modal-dialog.modal-md .md").toHaveCount(1);
|
||||
expect(".o_dialog .modal-dialog.modal-sm .sm").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("dialog can be rendered on fullscreen", async () => {
|
||||
expect.assertions(2);
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<Dialog fullscreen="true">content</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog .modal").toHaveClass("o_modal_full");
|
||||
});
|
||||
|
||||
test("can be the UI active element", async () => {
|
||||
expect.assertions(4);
|
||||
class Parent extends Component {
|
||||
static template = xml`<Dialog>content</Dialog>`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
setup() {
|
||||
this.ui = useService("ui");
|
||||
expect(this.ui.activeElement).toBe(document, {
|
||||
message:
|
||||
"UI active element should be the default (document) as Parent is not mounted yet",
|
||||
});
|
||||
onMounted(() => {
|
||||
expect(".modal").toHaveCount(1);
|
||||
expect(this.ui.activeElement).toBe(
|
||||
queryOne(".modal", { message: "UI active element should be the dialog modal" })
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
const parent = await mountWithCleanup(Parent);
|
||||
destroy(parent);
|
||||
await Promise.resolve();
|
||||
expect(getService("ui").activeElement).toBe(document, {
|
||||
message: "UI owner should be reset to the default (document)",
|
||||
});
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("dialog can't be moved on small screen", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<Dialog>content</Dialog>`;
|
||||
static components = { Dialog };
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(".modal-content").toHaveStyle({
|
||||
top: "0px",
|
||||
left: "0px",
|
||||
});
|
||||
|
||||
const header = queryOne(".modal-header");
|
||||
const headerRect = header.getBoundingClientRect();
|
||||
|
||||
// Even if the `dragAndDrop` is called, confirms that there are no effects
|
||||
await contains(header).dragAndDrop(".modal-content", {
|
||||
position: {
|
||||
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
|
||||
// so we need to move the dialog based on these coordinates.
|
||||
x: headerRect.x + headerRect.width / 2 + 20,
|
||||
y: headerRect.y + headerRect.height / 2 + 50,
|
||||
},
|
||||
});
|
||||
|
||||
expect(".modal-content").toHaveStyle({
|
||||
top: "0px",
|
||||
left: "0px",
|
||||
});
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("dialog can be moved", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<Dialog>content</Dialog>`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".modal-content").toHaveStyle({
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
});
|
||||
|
||||
const modalRect = queryOne(".modal").getBoundingClientRect();
|
||||
const header = queryOne(".modal-header");
|
||||
const headerRect = header.getBoundingClientRect();
|
||||
await contains(header).dragAndDrop(".modal-content", {
|
||||
position: {
|
||||
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
|
||||
// so we need to move the dialog based on these coordinates.
|
||||
x: headerRect.x + headerRect.width / 2 + 20,
|
||||
y: headerRect.y + headerRect.height / 2 + 50,
|
||||
},
|
||||
});
|
||||
expect(".modal-content").toHaveStyle({
|
||||
left: `${modalRect.y + 20}px`,
|
||||
top: `${modalRect.x + 50}px`,
|
||||
});
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("dialog's position is reset on resize", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<Dialog>content</Dialog>`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".modal-content").toHaveStyle({
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
});
|
||||
|
||||
const modalRect = queryOne(".modal").getBoundingClientRect();
|
||||
const header = queryOne(".modal-header");
|
||||
const headerRect = header.getBoundingClientRect();
|
||||
await contains(header).dragAndDrop(".modal-content", {
|
||||
position: {
|
||||
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
|
||||
// so we need to move the dialog based on these coordinates.
|
||||
x: headerRect.x + headerRect.width / 2 + 20,
|
||||
y: headerRect.y + headerRect.height / 2 + 50,
|
||||
},
|
||||
});
|
||||
expect(".modal-content").toHaveStyle({
|
||||
left: `${modalRect.y + 20}px`,
|
||||
top: `${modalRect.x + 50}px`,
|
||||
});
|
||||
|
||||
await resize();
|
||||
await animationFrame();
|
||||
expect(".modal-content").toHaveStyle({
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import { test, expect, beforeEach, describe } from "@odoo/hoot";
|
||||
import { click, press, queryAll, queryAllTexts, queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { getService, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { useAutofocus } from "@web/core/utils/hooks";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
beforeEach(async () => {
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
});
|
||||
|
||||
test("Simple rendering with a single dialog", async () => {
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="'Welcome'">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
getService("dialog").add(CustomDialog);
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Welcome");
|
||||
await click(".o_dialog footer button");
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Simple rendering and close a single dialog", async () => {
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="'Welcome'">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const removeDialog = getService("dialog").add(CustomDialog);
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Welcome");
|
||||
|
||||
removeDialog();
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
|
||||
// Call a second time, the close on the dialog.
|
||||
// As the dialog is already close, this call is just ignored. No error should be raised.
|
||||
removeDialog();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("rendering with two dialogs", async () => {
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="props.title">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
getService("dialog").add(CustomDialog, { title: "Hello" });
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Hello");
|
||||
|
||||
getService("dialog").add(CustomDialog, { title: "Sauron" });
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(2);
|
||||
expect(queryAllTexts("header .modal-title")).toEqual(["Hello", "Sauron"]);
|
||||
await click(".o_dialog footer button");
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Sauron");
|
||||
});
|
||||
|
||||
test("multiple dialogs can become the UI active element", async () => {
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="props.title">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
getService("dialog").add(CustomDialog, { title: "Hello" });
|
||||
await animationFrame();
|
||||
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
|
||||
getService("ui").activeElement
|
||||
);
|
||||
|
||||
getService("dialog").add(CustomDialog, { title: "Sauron" });
|
||||
await animationFrame();
|
||||
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
|
||||
getService("ui").activeElement
|
||||
);
|
||||
|
||||
getService("dialog").add(CustomDialog, { title: "Rafiki" });
|
||||
await animationFrame();
|
||||
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
|
||||
getService("ui").activeElement
|
||||
);
|
||||
});
|
||||
|
||||
test("a popover with an autofocus child can become the UI active element", async () => {
|
||||
class TestPopover extends Component {
|
||||
static template = xml`<input type="text" t-ref="autofocus" />`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
useAutofocus();
|
||||
}
|
||||
}
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="props.title">
|
||||
<button class="btn test" t-on-click="showPopover">show</button>
|
||||
</Dialog>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.popover = usePopover(TestPopover);
|
||||
}
|
||||
showPopover(event) {
|
||||
this.popover.open(event.target, {});
|
||||
}
|
||||
}
|
||||
|
||||
expect(document).toBe(getService("ui").activeElement);
|
||||
expect(document.body).toBeFocused();
|
||||
|
||||
getService("dialog").add(CustomDialog, { title: "Hello" });
|
||||
await animationFrame();
|
||||
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
|
||||
getService("ui").activeElement
|
||||
);
|
||||
expect(".btn.test").toBeFocused();
|
||||
|
||||
await click(".btn.test");
|
||||
await animationFrame();
|
||||
expect(queryOne(".o_popover")).toBe(getService("ui").activeElement);
|
||||
expect(".o_popover input").toBeFocused();
|
||||
});
|
||||
|
||||
test("Interactions between multiple dialogs", async () => {
|
||||
function activity(modals) {
|
||||
const active = [];
|
||||
const names = [];
|
||||
for (let i = 0; i < modals.length; i++) {
|
||||
active[i] = !modals[i].classList.contains("o_inactive_modal");
|
||||
names[i] = modals[i].querySelector(".modal-title").textContent;
|
||||
}
|
||||
return { active, names };
|
||||
}
|
||||
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="props.title">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
getService("dialog").add(CustomDialog, { title: "Hello" });
|
||||
await animationFrame();
|
||||
getService("dialog").add(CustomDialog, { title: "Sauron" });
|
||||
await animationFrame();
|
||||
getService("dialog").add(CustomDialog, { title: "Rafiki" });
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_dialog").toHaveCount(3);
|
||||
let res = activity(queryAll(".o_dialog"));
|
||||
expect(res.active).toEqual([false, false, true]);
|
||||
expect(res.names).toEqual(["Hello", "Sauron", "Rafiki"]);
|
||||
|
||||
await press("Escape", { bubbles: true });
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_dialog").toHaveCount(2);
|
||||
res = activity(queryAll(".o_dialog"));
|
||||
expect(res.active).toEqual([false, true]);
|
||||
expect(res.names).toEqual(["Hello", "Sauron"]);
|
||||
|
||||
await click(".o_dialog:not(.o_inactive_modal) footer button");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
res = activity(queryAll(".o_dialog"));
|
||||
expect(res.active).toEqual([true]);
|
||||
expect(res.names).toEqual(["Hello"]);
|
||||
|
||||
await click("footer button");
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dialog component crashes", async () => {
|
||||
expect.errors(1);
|
||||
|
||||
class FailingDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="'Error'">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
throw new Error("Some Error");
|
||||
}
|
||||
}
|
||||
|
||||
getService("dialog").add(FailingDialog);
|
||||
await animationFrame();
|
||||
|
||||
expect(".modal .o_error_dialog").toHaveCount(1);
|
||||
expect.verifyErrors(["Error: Some Error"]);
|
||||
});
|
||||
589
odoo-bringout-oca-ocb-web/web/static/tests/core/domain.test.js
Normal file
589
odoo-bringout-oca-ocb-web/web/static/tests/core/domain.test.js
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
|
||||
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { PyDate } from "@web/core/py_js/py_date";
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Basic properties
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
describe("Basic Properties", () => {
|
||||
test("empty", () => {
|
||||
expect(new Domain([]).contains({})).toBe(true);
|
||||
expect(new Domain([]).toString()).toBe("[]");
|
||||
expect(new Domain([]).toList()).toEqual([]);
|
||||
});
|
||||
|
||||
test("undefined domain", () => {
|
||||
expect(new Domain(undefined).contains({})).toBe(true);
|
||||
expect(new Domain(undefined).toString()).toBe("[]");
|
||||
expect(new Domain(undefined).toList()).toEqual([]);
|
||||
});
|
||||
|
||||
test("simple condition", () => {
|
||||
expect(new Domain([["a", "=", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "=", 3]]).contains({ a: 5 })).toBe(false);
|
||||
expect(new Domain([["a", "=", 3]]).toString()).toBe(`[("a", "=", 3)]`);
|
||||
expect(new Domain([["a", "=", 3]]).toList()).toEqual([["a", "=", 3]]);
|
||||
});
|
||||
|
||||
test("can be created from domain", () => {
|
||||
const domain = new Domain([["a", "=", 3]]);
|
||||
expect(new Domain(domain).toString()).toBe(`[("a", "=", 3)]`);
|
||||
});
|
||||
|
||||
test("basic", () => {
|
||||
const record = {
|
||||
a: 3,
|
||||
group_method: "line",
|
||||
select1: "day",
|
||||
rrule_type: "monthly",
|
||||
};
|
||||
expect(new Domain([["a", "=", 3]]).contains(record)).toBe(true);
|
||||
expect(new Domain([["a", "=", 5]]).contains(record)).toBe(false);
|
||||
expect(new Domain([["group_method", "!=", "count"]]).contains(record)).toBe(true);
|
||||
expect(
|
||||
new Domain([
|
||||
["select1", "=", "day"],
|
||||
["rrule_type", "=", "monthly"],
|
||||
]).contains(record)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("support of '=?' operator", () => {
|
||||
const record = { a: 3 };
|
||||
expect(new Domain([["a", "=?", null]]).contains(record)).toBe(true);
|
||||
expect(new Domain([["a", "=?", false]]).contains(record)).toBe(true);
|
||||
expect(new Domain(["!", ["a", "=?", false]]).contains(record)).toBe(false);
|
||||
expect(new Domain([["a", "=?", 1]]).contains(record)).toBe(false);
|
||||
expect(new Domain([["a", "=?", 3]]).contains(record)).toBe(true);
|
||||
expect(new Domain(["!", ["a", "=?", 3]]).contains(record)).toBe(false);
|
||||
});
|
||||
|
||||
test("or", () => {
|
||||
const currentDomain = [
|
||||
"|",
|
||||
["section_id", "=", 42],
|
||||
"|",
|
||||
["user_id", "=", 3],
|
||||
["member_ids", "in", [3]],
|
||||
];
|
||||
const record = {
|
||||
section_id: null,
|
||||
user_id: null,
|
||||
member_ids: null,
|
||||
};
|
||||
expect(new Domain(currentDomain).contains({ ...record, section_id: 42 })).toBe(true);
|
||||
expect(new Domain(currentDomain).contains({ ...record, user_id: 3 })).toBe(true);
|
||||
expect(new Domain(currentDomain).contains({ ...record, member_ids: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
test("and", () => {
|
||||
const domain = new Domain(["&", "&", ["a", "=", 1], ["b", "=", 2], ["c", "=", 3]]);
|
||||
|
||||
expect(domain.contains({ a: 1, b: 2, c: 3 })).toBe(true);
|
||||
expect(domain.contains({ a: -1, b: 2, c: 3 })).toBe(false);
|
||||
expect(domain.contains({ a: 1, b: -1, c: 3 })).toBe(false);
|
||||
expect(domain.contains({ a: 1, b: 2, c: -1 })).toBe(false);
|
||||
});
|
||||
|
||||
test("not", () => {
|
||||
const record = {
|
||||
a: 5,
|
||||
group_method: "line",
|
||||
};
|
||||
expect(new Domain(["!", ["a", "=", 3]]).contains(record)).toBe(true);
|
||||
expect(new Domain(["!", ["group_method", "=", "count"]]).contains(record)).toBe(true);
|
||||
});
|
||||
|
||||
test("like, =like, ilike, =ilike, not like and not ilike", () => {
|
||||
expect.assertions(28);
|
||||
|
||||
expect(new Domain([["a", "like", "value"]]).contains({ a: "value" })).toBe(true);
|
||||
expect(new Domain([["a", "like", "value"]]).contains({ a: "some value" })).toBe(true);
|
||||
expect(new Domain([["a", "like", "value"]]).contains({ a: "Some Value" })).not.toBe(true);
|
||||
expect(new Domain([["a", "like", "value"]]).contains({ a: false })).toBe(false);
|
||||
|
||||
expect(new Domain([["a", "=like", "%value"]]).contains({ a: "value" })).toBe(true);
|
||||
expect(new Domain([["a", "=like", "%value"]]).contains({ a: "some value" })).toBe(true);
|
||||
expect(new Domain([["a", "=like", "%value"]]).contains({ a: "Some Value" })).not.toBe(true);
|
||||
expect(new Domain([["a", "=like", "%value"]]).contains({ a: false })).toBe(false);
|
||||
|
||||
expect(new Domain([["a", "ilike", "value"]]).contains({ a: "value" })).toBe(true);
|
||||
expect(new Domain([["a", "ilike", "value"]]).contains({ a: "some value" })).toBe(true);
|
||||
expect(new Domain([["a", "ilike", "value"]]).contains({ a: "Some Value" })).toBe(true);
|
||||
expect(new Domain([["a", "ilike", "value"]]).contains({ a: false })).toBe(false);
|
||||
|
||||
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: "value" })).toBe(true);
|
||||
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: "some value" })).toBe(true);
|
||||
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: "Some Value" })).toBe(true);
|
||||
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: false })).toBe(false);
|
||||
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: "value" })).not.toBe(true);
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: "some value" })).not.toBe(
|
||||
true
|
||||
);
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: "Some Value" })).toBe(true);
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: "something" })).toBe(true);
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: "Something" })).toBe(true);
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: false })).toBe(false);
|
||||
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "value" })).not.toBe(true);
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "some value" })).toBe(false);
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "Some Value" })).toBe(false);
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "something" })).toBe(true);
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "Something" })).toBe(true);
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: false })).toBe(false);
|
||||
});
|
||||
|
||||
test("complex domain", () => {
|
||||
const domain = new Domain(["&", "!", ["a", "=", 1], "|", ["a", "=", 2], ["a", "=", 3]]);
|
||||
|
||||
expect(domain.contains({ a: 1 })).toBe(false);
|
||||
expect(domain.contains({ a: 2 })).toBe(true);
|
||||
expect(domain.contains({ a: 3 })).toBe(true);
|
||||
expect(domain.contains({ a: 4 })).toBe(false);
|
||||
});
|
||||
|
||||
test("toList", () => {
|
||||
expect(new Domain([]).toList()).toEqual([]);
|
||||
expect(new Domain([["a", "=", 3]]).toList()).toEqual([["a", "=", 3]]);
|
||||
expect(
|
||||
new Domain([
|
||||
["a", "=", 3],
|
||||
["b", "!=", "4"],
|
||||
]).toList()
|
||||
).toEqual(["&", ["a", "=", 3], ["b", "!=", "4"]]);
|
||||
expect(new Domain(["!", ["a", "=", 3]]).toList()).toEqual(["!", ["a", "=", 3]]);
|
||||
});
|
||||
|
||||
test("toString", () => {
|
||||
expect(new Domain([]).toString()).toBe(`[]`);
|
||||
expect(new Domain([["a", "=", 3]]).toString()).toBe(`[("a", "=", 3)]`);
|
||||
expect(
|
||||
new Domain([
|
||||
["a", "=", 3],
|
||||
["b", "!=", "4"],
|
||||
]).toString()
|
||||
).toBe(`["&", ("a", "=", 3), ("b", "!=", "4")]`);
|
||||
expect(new Domain(["!", ["a", "=", 3]]).toString()).toBe(`["!", ("a", "=", 3)]`);
|
||||
expect(new Domain([["name", "=", null]]).toString()).toBe('[("name", "=", None)]');
|
||||
expect(new Domain([["name", "=", false]]).toString()).toBe('[("name", "=", False)]');
|
||||
expect(new Domain([["name", "=", true]]).toString()).toBe('[("name", "=", True)]');
|
||||
expect(new Domain([["name", "=", "null"]]).toString()).toBe('[("name", "=", "null")]');
|
||||
expect(new Domain([["name", "=", "false"]]).toString()).toBe('[("name", "=", "false")]');
|
||||
expect(new Domain([["name", "=", "true"]]).toString()).toBe('[("name", "=", "true")]');
|
||||
expect(new Domain().toString()).toBe("[]");
|
||||
expect(new Domain([["name", "in", [true, false]]]).toString()).toBe(
|
||||
'[("name", "in", [True, False])]'
|
||||
);
|
||||
expect(new Domain([["name", "in", [null]]]).toString()).toBe('[("name", "in", [None])]');
|
||||
expect(new Domain([["name", "in", ["foo", "bar"]]]).toString()).toBe(
|
||||
'[("name", "in", ["foo", "bar"])]'
|
||||
);
|
||||
expect(new Domain([["name", "in", [1, 2]]]).toString()).toBe('[("name", "in", [1, 2])]');
|
||||
expect(new Domain(["&", ["name", "=", "foo"], ["type", "=", "bar"]]).toString()).toBe(
|
||||
'["&", ("name", "=", "foo"), ("type", "=", "bar")]'
|
||||
);
|
||||
expect(new Domain(["|", ["name", "=", "foo"], ["type", "=", "bar"]]).toString()).toBe(
|
||||
'["|", ("name", "=", "foo"), ("type", "=", "bar")]'
|
||||
);
|
||||
expect(new Domain().toString()).toBe("[]");
|
||||
|
||||
// string domains are only reformatted
|
||||
expect(new Domain('[("name","ilike","foo")]').toString()).toBe(
|
||||
'[("name", "ilike", "foo")]'
|
||||
);
|
||||
});
|
||||
|
||||
test("toJson", () => {
|
||||
expect(new Domain([]).toJson()).toEqual([]);
|
||||
expect(new Domain("[]").toJson()).toEqual([]);
|
||||
expect(new Domain([["a", "=", 3]]).toJson()).toEqual([["a", "=", 3]]);
|
||||
expect(new Domain('[("a", "=", 3)]').toJson()).toEqual([["a", "=", 3]]);
|
||||
expect(new Domain('[("user_id", "=", uid)]').toJson()).toBe('[("user_id", "=", uid)]');
|
||||
expect(new Domain('[("date", "=", context_today())]').toJson()).toBe(
|
||||
'[("date", "=", context_today())]'
|
||||
);
|
||||
});
|
||||
|
||||
test("implicit &", () => {
|
||||
const domain = new Domain([
|
||||
["a", "=", 3],
|
||||
["b", "=", 4],
|
||||
]);
|
||||
expect(domain.contains({})).toBe(false);
|
||||
expect(domain.contains({ a: 3, b: 4 })).toBe(true);
|
||||
expect(domain.contains({ a: 3, b: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("comparison operators", () => {
|
||||
expect(new Domain([["a", "=", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "=", 3]]).contains({ a: 4 })).toBe(false);
|
||||
expect(new Domain([["a", "=", 3]]).toString()).toBe(`[("a", "=", 3)]`);
|
||||
expect(new Domain([["a", "==", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "==", 3]]).contains({ a: 4 })).toBe(false);
|
||||
expect(new Domain([["a", "==", 3]]).toString()).toBe(`[("a", "==", 3)]`);
|
||||
expect(new Domain([["a", "!=", 3]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", "!=", 3]]).contains({ a: 4 })).toBe(true);
|
||||
expect(new Domain([["a", "!=", 3]]).toString()).toBe(`[("a", "!=", 3)]`);
|
||||
expect(new Domain([["a", "<>", 3]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", "<>", 3]]).contains({ a: 4 })).toBe(true);
|
||||
expect(new Domain([["a", "<>", 3]]).toString()).toBe(`[("a", "<>", 3)]`);
|
||||
expect(new Domain([["a", "<", 3]]).contains({ a: 5 })).toBe(false);
|
||||
expect(new Domain([["a", "<", 3]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", "<", 3]]).contains({ a: 2 })).toBe(true);
|
||||
expect(new Domain([["a", "<", 3]]).toString()).toBe(`[("a", "<", 3)]`);
|
||||
expect(new Domain([["a", "<=", 3]]).contains({ a: 5 })).toBe(false);
|
||||
expect(new Domain([["a", "<=", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "<=", 3]]).contains({ a: 2 })).toBe(true);
|
||||
expect(new Domain([["a", "<=", 3]]).toString()).toBe(`[("a", "<=", 3)]`);
|
||||
expect(new Domain([["a", ">", 3]]).contains({ a: 5 })).toBe(true);
|
||||
expect(new Domain([["a", ">", 3]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", ">", 3]]).contains({ a: 2 })).toBe(false);
|
||||
expect(new Domain([["a", ">", 3]]).toString()).toBe(`[("a", ">", 3)]`);
|
||||
expect(new Domain([["a", ">=", 3]]).contains({ a: 5 })).toBe(true);
|
||||
expect(new Domain([["a", ">=", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", ">=", 3]]).contains({ a: 2 })).toBe(false);
|
||||
expect(new Domain([["a", ">=", 3]]).toString()).toBe(`[("a", ">=", 3)]`);
|
||||
});
|
||||
|
||||
test("other operators", () => {
|
||||
expect(new Domain([["a", "in", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [3] })).toBe(true);
|
||||
expect(new Domain([["a", "in", 3]]).contains({ a: 5 })).toBe(false);
|
||||
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 5 })).toBe(false);
|
||||
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [5] })).toBe(false);
|
||||
expect(new Domain([["a", "not in", 3]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [3] })).toBe(false);
|
||||
expect(new Domain([["a", "not in", 3]]).contains({ a: 5 })).toBe(true);
|
||||
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 5 })).toBe(true);
|
||||
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [5] })).toBe(true);
|
||||
expect(new Domain([["a", "like", "abc"]]).contains({ a: "abc" })).toBe(true);
|
||||
expect(new Domain([["a", "like", "abc"]]).contains({ a: "def" })).toBe(false);
|
||||
expect(new Domain([["a", "=like", "abc"]]).contains({ a: "abc" })).toBe(true);
|
||||
expect(new Domain([["a", "=like", "abc"]]).contains({ a: "def" })).toBe(false);
|
||||
expect(new Domain([["a", "ilike", "abc"]]).contains({ a: "abc" })).toBe(true);
|
||||
expect(new Domain([["a", "ilike", "abc"]]).contains({ a: "def" })).toBe(false);
|
||||
expect(new Domain([["a", "=ilike", "abc"]]).contains({ a: "abc" })).toBe(true);
|
||||
expect(new Domain([["a", "=ilike", "abc"]]).contains({ a: "def" })).toBe(false);
|
||||
});
|
||||
|
||||
test("creating a domain with a string expression", () => {
|
||||
expect(new Domain(`[('a', '>=', 3)]`).toString()).toBe(`[("a", ">=", 3)]`);
|
||||
expect(new Domain(`[('a', '>=', 3)]`).contains({ a: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
test("can evaluate a python expression", () => {
|
||||
expect(new Domain(`[('date', '!=', False)]`).toList()).toEqual([["date", "!=", false]]);
|
||||
expect(new Domain(`[('date', '!=', False)]`).toList()).toEqual([["date", "!=", false]]);
|
||||
expect(new Domain(`[('date', '!=', 1 + 2)]`).toString()).toBe(`[("date", "!=", 1 + 2)]`);
|
||||
expect(new Domain(`[('date', '!=', 1 + 2)]`).toList()).toEqual([["date", "!=", 3]]);
|
||||
expect(new Domain(`[('a', '==', 1 + 2)]`).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain(`[('a', '==', 1 + 2)]`).contains({ a: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
test("some expression with date stuff", () => {
|
||||
patchWithCleanup(PyDate, {
|
||||
today() {
|
||||
return new PyDate(2013, 4, 24);
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
new Domain(
|
||||
"[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"
|
||||
).toList()
|
||||
).toEqual([["date", ">=", "2013-03-25"]]);
|
||||
|
||||
const domainList = new Domain(
|
||||
"[('date', '>=', context_today() - relativedelta(days=30))]"
|
||||
).toList(); // domain creation using `parseExpr` function since the parameter is a string.
|
||||
|
||||
expect(domainList[0][2]).toEqual(PyDate.create({ day: 25, month: 3, year: 2013 }), {
|
||||
message: "The right item in the rule in the domain should be a PyDate object",
|
||||
});
|
||||
expect(JSON.stringify(domainList)).toBe('[["date",">=","2013-03-25"]]');
|
||||
|
||||
const domainList2 = new Domain(domainList).toList(); // domain creation using `toAST` function since the parameter is a list.
|
||||
expect(domainList2[0][2]).toEqual(PyDate.create({ day: 25, month: 3, year: 2013 }), {
|
||||
message: "The right item in the rule in the domain should be a PyDate object",
|
||||
});
|
||||
expect(JSON.stringify(domainList2)).toBe('[["date",">=","2013-03-25"]]');
|
||||
});
|
||||
|
||||
test("Check that there is no dependency between two domains", () => {
|
||||
// The purpose of this test is to verify that a domain created on the basis
|
||||
// of another one does not share any dependency.
|
||||
const domain1 = new Domain(`[('date', '!=', False)]`);
|
||||
const domain2 = new Domain(domain1);
|
||||
expect(domain1.toString()).toBe(domain2.toString());
|
||||
|
||||
domain2.ast.value.unshift({ type: 1, value: "!" });
|
||||
expect(domain1.toString()).not.toBe(domain2.toString());
|
||||
});
|
||||
|
||||
test("TRUE and FALSE Domain", () => {
|
||||
expect(Domain.TRUE.contains({})).toBe(true);
|
||||
expect(Domain.FALSE.contains({})).toBe(false);
|
||||
|
||||
expect(Domain.and([Domain.TRUE, new Domain([["a", "=", 3]])]).contains({ a: 3 })).toBe(
|
||||
true
|
||||
);
|
||||
expect(Domain.and([Domain.FALSE, new Domain([["a", "=", 3]])]).contains({ a: 3 })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid domains should not succeed", () => {
|
||||
expect(() => new Domain(["|", ["hr_presence_state", "=", "absent"]])).toThrow(
|
||||
/invalid domain .* \(missing 1 segment/
|
||||
);
|
||||
expect(
|
||||
() =>
|
||||
new Domain([
|
||||
"|",
|
||||
"|",
|
||||
["hr_presence_state", "=", "absent"],
|
||||
["attendance_state", "=", "checked_in"],
|
||||
])
|
||||
).toThrow(/invalid domain .* \(missing 1 segment/);
|
||||
expect(() => new Domain(["|", "|", ["hr_presence_state", "=", "absent"]])).toThrow(
|
||||
/invalid domain .* \(missing 2 segment\(s\)/
|
||||
);
|
||||
expect(() => new Domain(["&", ["composition_mode", "!=", "mass_post"]])).toThrow(
|
||||
/invalid domain .* \(missing 1 segment/
|
||||
);
|
||||
expect(() => new Domain(["!"])).toThrow(/invalid domain .* \(missing 1 segment/);
|
||||
expect(() => new Domain(`[(1, 2)]`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`[(1, 2, 3, 4)]`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`["a"]`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`[1]`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`[x]`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`[True]`)).toThrow(/Invalid domain AST/); // will possibly change with CHM work
|
||||
expect(() => new Domain(`[(x.=, "=", 1)]`)).toThrow(/Invalid domain representation/);
|
||||
expect(() => new Domain(`[(+, "=", 1)]`)).toThrow(/Invalid domain representation/);
|
||||
expect(() => new Domain([{}])).toThrow(/Invalid domain representation/);
|
||||
expect(() => new Domain([1])).toThrow(/Invalid domain representation/);
|
||||
});
|
||||
|
||||
test("follow relations", () => {
|
||||
expect(
|
||||
new Domain([["partner.city", "ilike", "Bru"]]).contains({
|
||||
name: "Lucas",
|
||||
partner: {
|
||||
city: "Bruxelles",
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
new Domain([["partner.city.name", "ilike", "Bru"]]).contains({
|
||||
name: "Lucas",
|
||||
partner: {
|
||||
city: {
|
||||
name: "Bruxelles",
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Arrays comparison", () => {
|
||||
const domain = new Domain(["&", ["a", "==", []], ["b", "!=", []]]);
|
||||
|
||||
expect(domain.contains({ a: [] })).toBe(true);
|
||||
expect(domain.contains({ a: [], b: [4] })).toBe(true);
|
||||
expect(domain.contains({ a: [1] })).toBe(false);
|
||||
expect(domain.contains({ b: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Normalization", () => {
|
||||
test("return simple (normalized) domains", () => {
|
||||
const domains = ["[]", `[("a", "=", 1)]`, `["!", ("a", "=", 1)]`];
|
||||
for (const domain of domains) {
|
||||
expect(new Domain(domain).toString()).toBe(domain);
|
||||
}
|
||||
});
|
||||
|
||||
test("properly add the & in a non normalized domain", () => {
|
||||
expect(new Domain(`[("a", "=", 1), ("b", "=", 2)]`).toString()).toBe(
|
||||
`["&", ("a", "=", 1), ("b", "=", 2)]`
|
||||
);
|
||||
});
|
||||
|
||||
test("normalize domain with ! operator", () => {
|
||||
expect(new Domain(`["!", ("a", "=", 1), ("b", "=", 2)]`).toString()).toBe(
|
||||
`["&", "!", ("a", "=", 1), ("b", "=", 2)]`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combining domains
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Combining domains", () => {
|
||||
test("combining zero domain", () => {
|
||||
expect(Domain.combine([], "AND").toString()).toBe("[]");
|
||||
expect(Domain.combine([], "OR").toString()).toBe("[]");
|
||||
expect(Domain.combine([], "AND").contains({ a: 1, b: 2 })).toBe(true);
|
||||
});
|
||||
|
||||
test("combining one domain", () => {
|
||||
expect(Domain.combine([`[("a", "=", 1)]`], "AND").toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.combine([`[("user_id", "=", uid)]`], "AND").toString()).toBe(
|
||||
`[("user_id", "=", uid)]`
|
||||
);
|
||||
expect(Domain.combine([[["a", "=", 1]]], "AND").toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.combine(["[('a', '=', '1'), ('b', '!=', 2)]"], "AND").toString()).toBe(
|
||||
`["&", ("a", "=", "1"), ("b", "!=", 2)]`
|
||||
);
|
||||
});
|
||||
|
||||
test("combining two domains", () => {
|
||||
expect(Domain.combine([`[("a", "=", 1)]`, "[]"], "AND").toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.combine([`[("a", "=", 1)]`, []], "AND").toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.combine([new Domain(`[("a", "=", 1)]`), "[]"], "AND").toString()).toBe(
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
expect(Domain.combine([new Domain(`[("a", "=", 1)]`), "[]"], "OR").toString()).toBe(
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
expect(Domain.combine([[["a", "=", 1]], "[('uid', '<=', uid)]"], "AND").toString()).toBe(
|
||||
`["&", ("a", "=", 1), ("uid", "<=", uid)]`
|
||||
);
|
||||
expect(Domain.combine([[["a", "=", 1]], "[('b', '<=', 3)]"], "OR").toString()).toBe(
|
||||
`["|", ("a", "=", 1), ("b", "<=", 3)]`
|
||||
);
|
||||
expect(
|
||||
Domain.combine(
|
||||
["[('a', '=', '1'), ('c', 'in', [4, 5])]", "[('b', '<=', 3)]"],
|
||||
"OR"
|
||||
).toString()
|
||||
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
|
||||
expect(
|
||||
Domain.combine(
|
||||
[new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"), "[('b', '<=', 3)]"],
|
||||
"OR"
|
||||
).toString()
|
||||
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
|
||||
});
|
||||
|
||||
test("combining three domains", () => {
|
||||
expect(
|
||||
Domain.combine(
|
||||
[
|
||||
new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"),
|
||||
[["b", "<=", 3]],
|
||||
`['!', ('uid', '=', uid)]`,
|
||||
],
|
||||
"OR"
|
||||
).toString()
|
||||
).toBe(
|
||||
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), "|", ("b", "<=", 3), "!", ("uid", "=", uid)]`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OPERATOR AND / OR / NOT
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Operator and - or - not", () => {
|
||||
test("combining two domains with and/or", () => {
|
||||
expect(Domain.and([`[("a", "=", 1)]`, "[]"]).toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.and([`[("a", "=", 1)]`, []]).toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.and([new Domain(`[("a", "=", 1)]`), "[]"]).toString()).toBe(
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
expect(Domain.or([new Domain(`[("a", "=", 1)]`), "[]"]).toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.and([[["a", "=", 1]], "[('uid', '<=', uid)]"]).toString()).toBe(
|
||||
`["&", ("a", "=", 1), ("uid", "<=", uid)]`
|
||||
);
|
||||
expect(Domain.or([[["a", "=", 1]], "[('b', '<=', 3)]"]).toString()).toBe(
|
||||
`["|", ("a", "=", 1), ("b", "<=", 3)]`
|
||||
);
|
||||
expect(
|
||||
Domain.or(["[('a', '=', '1'), ('c', 'in', [4, 5])]", "[('b', '<=', 3)]"]).toString()
|
||||
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
|
||||
expect(
|
||||
Domain.or([
|
||||
new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"),
|
||||
"[('b', '<=', 3)]",
|
||||
]).toString()
|
||||
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
|
||||
});
|
||||
|
||||
test("apply `NOT` on a Domain", () => {
|
||||
expect(Domain.not("[('a', '=', 1)]").toString()).toBe(`["!", ("a", "=", 1)]`);
|
||||
expect(Domain.not('[("uid", "<=", uid)]').toString()).toBe(`["!", ("uid", "<=", uid)]`);
|
||||
expect(Domain.not(new Domain("[('a', '=', 1)]")).toString()).toBe(`["!", ("a", "=", 1)]`);
|
||||
expect(Domain.not(new Domain([["a", "=", 1]])).toString()).toBe(`["!", ("a", "=", 1)]`);
|
||||
});
|
||||
|
||||
test("tuple are supported", () => {
|
||||
expect(
|
||||
new Domain(`(("field", "like", "string"), ("field", "like", "strOng"))`).toList()
|
||||
).toEqual(["&", ["field", "like", "string"], ["field", "like", "strOng"]]);
|
||||
expect(new Domain(`("!",("field", "like", "string"))`).toList()).toEqual([
|
||||
"!",
|
||||
["field", "like", "string"],
|
||||
]);
|
||||
expect(() => new Domain(`(("field", "like", "string"))`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`("&", "&", "|")`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`("&", "&", 3)`)).toThrow(/Invalid domain AST/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Remove domain leaf", () => {
|
||||
test("Remove leaf in domain.", () => {
|
||||
let domain = [
|
||||
["start_datetime", "!=", false],
|
||||
["end_datetime", "!=", false],
|
||||
["sale_line_id", "!=", false],
|
||||
];
|
||||
const keysToRemove = ["start_datetime", "end_datetime"];
|
||||
let newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
|
||||
let expectedDomain = new Domain([
|
||||
"&",
|
||||
...Domain.TRUE.toList({}),
|
||||
...Domain.TRUE.toList({}),
|
||||
["sale_line_id", "!=", false],
|
||||
]);
|
||||
expect(newDomain.toList({})).toEqual(expectedDomain.toList({}));
|
||||
domain = [
|
||||
"|",
|
||||
["role_id", "=", false],
|
||||
"&",
|
||||
["resource_id", "!=", false],
|
||||
["start_datetime", "=", false],
|
||||
["sale_line_id", "!=", false],
|
||||
];
|
||||
newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
|
||||
expectedDomain = new Domain([
|
||||
"|",
|
||||
["role_id", "=", false],
|
||||
"&",
|
||||
["resource_id", "!=", false],
|
||||
...Domain.TRUE.toList({}),
|
||||
["sale_line_id", "!=", false],
|
||||
]);
|
||||
expect(newDomain.toList({})).toEqual(expectedDomain.toList({}));
|
||||
domain = [
|
||||
"|",
|
||||
["start_datetime", "=", false],
|
||||
["end_datetime", "=", false],
|
||||
["sale_line_id", "!=", false],
|
||||
];
|
||||
newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
|
||||
expectedDomain = new Domain([...Domain.TRUE.toList({}), ["sale_line_id", "!=", false]]);
|
||||
expect(newDomain.toList({})).toEqual(expectedDomain.toList({}));
|
||||
});
|
||||
});
|
||||
1200
odoo-bringout-oca-ocb-web/web/static/tests/core/domain_field.test.js
Normal file
1200
odoo-bringout-oca-ocb-web/web/static/tests/core/domain_field.test.js
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { SELECTORS as treeEditorSELECTORS } from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
|
||||
|
||||
export const SELECTORS = {
|
||||
...treeEditorSELECTORS,
|
||||
debugArea: ".o_domain_selector_debug_container textarea",
|
||||
resetButton: ".o_domain_selector_row > button",
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { click } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
|
||||
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
|
||||
const DROPDOWN_TOGGLE = ".o-dropdown.dropdown-toggle";
|
||||
const DROPDOWN_MENU = ".o-dropdown--menu.dropdown-menu";
|
||||
const DROPDOWN_ITEM = ".o-dropdown-item.dropdown-item:not(.o-dropdown)";
|
||||
|
||||
test("can be rendered as <span/>", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { DropdownItem };
|
||||
static props = [];
|
||||
static template = xml`<DropdownItem>coucou</DropdownItem>`;
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(".dropdown-item").toHaveClass(["o-dropdown-item", "o-navigable", "dropdown-item"]);
|
||||
expect(".dropdown-item").toHaveAttribute("role", "menuitem");
|
||||
});
|
||||
|
||||
test("(with href prop) can be rendered as <a/>", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { DropdownItem };
|
||||
static props = [];
|
||||
static template = xml`<DropdownItem attrs="{ href: '#' }">coucou</DropdownItem>`;
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
expect(DROPDOWN_ITEM).toHaveAttribute("href", "#");
|
||||
});
|
||||
|
||||
test("prevents click default with href", async () => {
|
||||
expect.assertions(4);
|
||||
// A DropdownItem should preventDefault a click as it may take the shape
|
||||
// of an <a/> tag with an [href] attribute and e.g. could change the url when clicked.
|
||||
patchWithCleanup(DropdownItem.prototype, {
|
||||
onClick(ev) {
|
||||
expect(!ev.defaultPrevented).toBe(true);
|
||||
super.onClick(...arguments);
|
||||
const href = ev.target.getAttribute("href");
|
||||
// defaultPrevented only if props.href is defined
|
||||
expect(href !== null ? ev.defaultPrevented : !ev.defaultPrevented).toBe(true);
|
||||
},
|
||||
});
|
||||
class Parent extends Component {
|
||||
static components = { Dropdown, DropdownItem };
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<Dropdown>
|
||||
<button>Coucou</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem class="'link'" attrs="{href: '#'}"/>
|
||||
<DropdownItem class="'nolink'" />
|
||||
</t>
|
||||
</Dropdown>`;
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
// The item containing the link class contains an href prop,
|
||||
// which will turn it into <a href=> So it must be defaultPrevented
|
||||
// The other one not contain any href props, it must not be defaultPrevented,
|
||||
// so as not to prevent the background change flow for example
|
||||
await click(DROPDOWN_TOGGLE);
|
||||
await animationFrame();
|
||||
await click(".link");
|
||||
await click("button.dropdown-toggle");
|
||||
await click(".nolink");
|
||||
});
|
||||
|
||||
test("can be styled", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { Dropdown, DropdownItem };
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<Dropdown menuClass="'test-menu'">
|
||||
<button class="test-toggler">Coucou</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem class="'test-dropdown-item'">Item</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(DROPDOWN_TOGGLE).toHaveClass("test-toggler");
|
||||
|
||||
await click(DROPDOWN_TOGGLE);
|
||||
await animationFrame();
|
||||
expect(DROPDOWN_MENU).toHaveClass("test-menu");
|
||||
expect(DROPDOWN_ITEM).toHaveClass("test-dropdown-item");
|
||||
});
|
||||
|
|
@ -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>`);
|
||||
});
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import { browser } from "@web/core/browser/browser";
|
||||
import { describe, test, expect } from "@odoo/hoot";
|
||||
import { animationFrame, tick } from "@odoo/hoot-mock";
|
||||
import {
|
||||
mountWithCleanup,
|
||||
patchWithCleanup,
|
||||
mockService,
|
||||
makeDialogMockEnv,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { click, freezeTime, queryAllTexts } from "@odoo/hoot-dom";
|
||||
import {
|
||||
ClientErrorDialog,
|
||||
Error504Dialog,
|
||||
ErrorDialog,
|
||||
RedirectWarningDialog,
|
||||
SessionExpiredDialog,
|
||||
WarningDialog,
|
||||
} from "@web/core/errors/error_dialogs";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("ErrorDialog with traceback", async () => {
|
||||
freezeTime();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ErrorDialog, {
|
||||
env,
|
||||
props: {
|
||||
message: "Something bad happened",
|
||||
data: { debug: "Some strange unreadable stack" },
|
||||
name: "ERROR_NAME",
|
||||
traceback: "This is a traceback string",
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Oops!");
|
||||
expect("main button").toHaveText("See technical details");
|
||||
expect(queryAllTexts("footer button")).toEqual(["Close"]);
|
||||
expect("main p").toHaveText(
|
||||
"Something went wrong... If you really are stuck, share the report with your friendly support service"
|
||||
);
|
||||
expect("div.o_error_detail").toHaveCount(0);
|
||||
await click("main button");
|
||||
await animationFrame();
|
||||
expect(queryAllTexts("main .clearfix p")).toEqual([
|
||||
"Odoo Error",
|
||||
"Something bad happened",
|
||||
"Occured on 2019-03-11 09:30:00 GMT",
|
||||
]);
|
||||
expect("main .clearfix code").toHaveText("ERROR_NAME");
|
||||
expect("div.o_error_detail").toHaveCount(1);
|
||||
expect("div.o_error_detail pre").toHaveText("This is a traceback string");
|
||||
});
|
||||
|
||||
test("Client ErrorDialog with traceback", async () => {
|
||||
freezeTime();
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ClientErrorDialog, {
|
||||
env,
|
||||
props: {
|
||||
message: "Something bad happened",
|
||||
data: { debug: "Some strange unreadable stack" },
|
||||
name: "ERROR_NAME",
|
||||
traceback: "This is a traceback string",
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Oops!");
|
||||
expect("main button").toHaveText("See technical details");
|
||||
expect(queryAllTexts("footer button")).toEqual(["Close"]);
|
||||
expect("main p").toHaveText(
|
||||
"Something went wrong... If you really are stuck, share the report with your friendly support service"
|
||||
);
|
||||
expect("div.o_error_detail").toHaveCount(0);
|
||||
await click("main button");
|
||||
await animationFrame();
|
||||
expect(queryAllTexts("main .clearfix p")).toEqual([
|
||||
"Odoo Client Error",
|
||||
"Something bad happened",
|
||||
"Occured on 2019-03-11 09:30:00 GMT",
|
||||
]);
|
||||
expect("main .clearfix code").toHaveText("ERROR_NAME");
|
||||
expect("div.o_error_detail").toHaveCount(1);
|
||||
expect("div.o_error_detail pre").toHaveText("This is a traceback string");
|
||||
});
|
||||
|
||||
test("button clipboard copy error traceback", async () => {
|
||||
freezeTime();
|
||||
expect.assertions(1);
|
||||
const error = new Error();
|
||||
error.name = "ERROR_NAME";
|
||||
error.message = "This is the message";
|
||||
error.traceback = "This is a traceback";
|
||||
patchWithCleanup(navigator.clipboard, {
|
||||
writeText(value) {
|
||||
expect(value).toBe(
|
||||
`${error.name}\n\n${error.message}\n\nOccured on 2019-03-11 09:30:00 GMT\n\n${error.traceback}`
|
||||
);
|
||||
},
|
||||
});
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ErrorDialog, {
|
||||
env,
|
||||
props: {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
traceback: error.traceback,
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
await click("main button");
|
||||
await animationFrame();
|
||||
await click(".fa-clone");
|
||||
await tick();
|
||||
});
|
||||
|
||||
test("Display a tooltip on clicking copy button", async () => {
|
||||
expect.assertions(1);
|
||||
mockService("popover", () => ({
|
||||
add(el, comp, params) {
|
||||
expect(params).toEqual({ tooltip: "Copied" });
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ErrorDialog, {
|
||||
env,
|
||||
props: {
|
||||
message: "This is the message",
|
||||
name: "ERROR_NAME",
|
||||
traceback: "This is a traceback",
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
await click("main button");
|
||||
await animationFrame();
|
||||
await click(".fa-clone");
|
||||
});
|
||||
|
||||
test("WarningDialog", async () => {
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(WarningDialog, {
|
||||
env,
|
||||
props: {
|
||||
exceptionName: "odoo.exceptions.UserError",
|
||||
message: "...",
|
||||
data: { arguments: ["Some strange unreadable message"] },
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Invalid Operation");
|
||||
expect(".o_error_dialog").toHaveCount(1);
|
||||
expect("main").toHaveText("Some strange unreadable message");
|
||||
expect(".o_dialog footer button").toHaveText("Close");
|
||||
});
|
||||
|
||||
test("RedirectWarningDialog", async () => {
|
||||
mockService("action", {
|
||||
doAction(actionId) {
|
||||
expect.step(actionId);
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(RedirectWarningDialog, {
|
||||
env,
|
||||
props: {
|
||||
data: {
|
||||
arguments: [
|
||||
"Some strange unreadable message",
|
||||
"buy_action_id",
|
||||
"Buy book on cryptography",
|
||||
],
|
||||
},
|
||||
close() {
|
||||
expect.step("dialog-closed");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Odoo Warning");
|
||||
expect("main").toHaveText("Some strange unreadable message");
|
||||
expect(queryAllTexts("footer button")).toEqual(["Buy book on cryptography", "Close"]);
|
||||
|
||||
await click("footer button:nth-child(1)"); // click on "Buy book on cryptography"
|
||||
await animationFrame();
|
||||
expect.verifySteps(["buy_action_id", "dialog-closed"]);
|
||||
|
||||
await click("footer button:nth-child(2)"); // click on "Cancel"
|
||||
await animationFrame();
|
||||
expect.verifySteps(["dialog-closed"]);
|
||||
});
|
||||
|
||||
test("Error504Dialog", async () => {
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(Error504Dialog, { env, props: { close() {} } });
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Request timeout");
|
||||
expect("main p").toHaveText(
|
||||
"The operation was interrupted. This usually means that the current operation is taking too much time."
|
||||
);
|
||||
expect(".o_dialog footer button").toHaveText("Close");
|
||||
});
|
||||
|
||||
test("SessionExpiredDialog", async () => {
|
||||
patchWithCleanup(browser.location, {
|
||||
reload() {
|
||||
expect.step("location reload");
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(SessionExpiredDialog, { env, props: { close() {} } });
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Odoo Session Expired");
|
||||
expect("main p").toHaveText(
|
||||
"Your Odoo session expired. The current page is about to be refreshed."
|
||||
);
|
||||
expect(".o_dialog footer button").toHaveText("Close");
|
||||
await click(".o_dialog footer button");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["location reload"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,529 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom";
|
||||
import { Deferred, advanceTime, animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, OwlError, onError, onWillStart, xml } from "@odoo/owl";
|
||||
import {
|
||||
makeMockEnv,
|
||||
mockService,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
serverState,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import {
|
||||
ClientErrorDialog,
|
||||
RPCErrorDialog,
|
||||
standardErrorDialogProps,
|
||||
} from "@web/core/errors/error_dialogs";
|
||||
import { UncaughtPromiseError } from "@web/core/errors/error_service";
|
||||
import { ConnectionLostError, RPCError } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { omit } from "@web/core/utils/objects";
|
||||
|
||||
const errorDialogRegistry = registry.category("error_dialogs");
|
||||
const errorHandlerRegistry = registry.category("error_handlers");
|
||||
|
||||
test("can handle rejected promise errors with a string as reason", async () => {
|
||||
expect.assertions(2);
|
||||
expect.errors(1);
|
||||
await makeMockEnv();
|
||||
errorHandlerRegistry.add(
|
||||
"__test_handler__",
|
||||
(env, err, originalError) => {
|
||||
expect(originalError).toBe("-- something went wrong --");
|
||||
},
|
||||
{ sequence: 0 }
|
||||
);
|
||||
Promise.reject("-- something went wrong --");
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["-- something went wrong --"]);
|
||||
});
|
||||
|
||||
test("handle RPC_ERROR of type='server' and no associated dialog class", async () => {
|
||||
expect.assertions(5);
|
||||
expect.errors(1);
|
||||
const error = new RPCError();
|
||||
error.code = 701;
|
||||
error.message = "Some strange error occured";
|
||||
error.data = { debug: "somewhere" };
|
||||
error.subType = "strange_error";
|
||||
error.id = 12;
|
||||
error.model = "some model";
|
||||
|
||||
mockService("dialog", {
|
||||
add(dialogClass, props) {
|
||||
expect(dialogClass).toBe(RPCErrorDialog);
|
||||
expect(omit(props, "traceback", "serverHost")).toEqual({
|
||||
name: "RPC_ERROR",
|
||||
type: "server",
|
||||
code: 701,
|
||||
data: {
|
||||
debug: "somewhere",
|
||||
},
|
||||
subType: "strange_error",
|
||||
message: "Some strange error occured",
|
||||
exceptionName: null,
|
||||
id: 12,
|
||||
model: "some model",
|
||||
});
|
||||
expect(props.traceback).toMatch(/RPC_ERROR/);
|
||||
expect(props.traceback).toMatch(/Some strange error occured/);
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["RPC_ERROR: Some strange error occured"]);
|
||||
});
|
||||
|
||||
test("handle custom RPC_ERROR of type='server' and associated custom dialog class", async () => {
|
||||
expect.assertions(5);
|
||||
expect.errors(1);
|
||||
class CustomDialog extends Component {
|
||||
static template = xml`<RPCErrorDialog title="'Strange Error'"/>`;
|
||||
static components = { RPCErrorDialog };
|
||||
static props = { ...standardErrorDialogProps };
|
||||
}
|
||||
const error = new RPCError();
|
||||
error.code = 701;
|
||||
error.message = "Some strange error occured";
|
||||
error.id = 12;
|
||||
error.model = "some model";
|
||||
const errorData = {
|
||||
context: { exception_class: "strange_error" },
|
||||
name: "strange_error",
|
||||
};
|
||||
error.data = errorData;
|
||||
|
||||
mockService("dialog", {
|
||||
add(dialogClass, props) {
|
||||
expect(dialogClass).toBe(CustomDialog);
|
||||
expect(omit(props, "traceback", "serverHost")).toEqual({
|
||||
name: "RPC_ERROR",
|
||||
type: "server",
|
||||
code: 701,
|
||||
data: errorData,
|
||||
subType: null,
|
||||
message: "Some strange error occured",
|
||||
exceptionName: null,
|
||||
id: 12,
|
||||
model: "some model",
|
||||
});
|
||||
expect(props.traceback).toMatch(/RPC_ERROR/);
|
||||
expect(props.traceback).toMatch(/Some strange error occured/);
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
errorDialogRegistry.add("strange_error", CustomDialog);
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["RPC_ERROR: Some strange error occured"]);
|
||||
});
|
||||
|
||||
test("handle normal RPC_ERROR of type='server' and associated custom dialog class", async () => {
|
||||
expect.assertions(5);
|
||||
expect.errors(1);
|
||||
class CustomDialog extends Component {
|
||||
static template = xml`<RPCErrorDialog title="'Strange Error'"/>`;
|
||||
static components = { RPCErrorDialog };
|
||||
static props = ["*"];
|
||||
}
|
||||
class NormalDialog extends Component {
|
||||
static template = xml`<RPCErrorDialog title="'Normal Error'"/>`;
|
||||
static components = { RPCErrorDialog };
|
||||
static props = ["*"];
|
||||
}
|
||||
const error = new RPCError();
|
||||
error.code = 701;
|
||||
error.message = "A normal error occured";
|
||||
const errorData = {
|
||||
context: { exception_class: "strange_error" },
|
||||
};
|
||||
error.exceptionName = "normal_error";
|
||||
error.data = errorData;
|
||||
error.id = 12;
|
||||
error.model = "some model";
|
||||
mockService("dialog", {
|
||||
add(dialogClass, props) {
|
||||
expect(dialogClass).toBe(NormalDialog);
|
||||
expect(omit(props, "traceback", "serverHost")).toEqual({
|
||||
name: "RPC_ERROR",
|
||||
type: "server",
|
||||
code: 701,
|
||||
data: errorData,
|
||||
subType: null,
|
||||
message: "A normal error occured",
|
||||
exceptionName: "normal_error",
|
||||
id: 12,
|
||||
model: "some model",
|
||||
});
|
||||
expect(props.traceback).toMatch(/RPC_ERROR/);
|
||||
expect(props.traceback).toMatch(/A normal error occured/);
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
errorDialogRegistry.add("strange_error", CustomDialog);
|
||||
errorDialogRegistry.add("normal_error", NormalDialog);
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["RPC_ERROR: A normal error occured"]);
|
||||
});
|
||||
|
||||
test("handle CONNECTION_LOST_ERROR", async () => {
|
||||
expect.errors(1);
|
||||
mockService("notification", {
|
||||
add(message) {
|
||||
expect.step(`create (${message})`);
|
||||
return () => {
|
||||
expect.step(`close`);
|
||||
};
|
||||
},
|
||||
});
|
||||
const values = [false, true]; // simulate the 'back online status' after 2 'version_info' calls
|
||||
onRpc("/web/webclient/version_info", async () => {
|
||||
expect.step("version_info");
|
||||
const online = values.shift();
|
||||
if (online) {
|
||||
return true;
|
||||
} else {
|
||||
return Promise.reject();
|
||||
}
|
||||
});
|
||||
|
||||
await makeMockEnv();
|
||||
const error = new ConnectionLostError("/fake_url");
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
patchWithCleanup(Math, {
|
||||
random: () => 0,
|
||||
});
|
||||
// wait for timeouts
|
||||
await advanceTime(2000);
|
||||
await advanceTime(3500);
|
||||
expect.verifySteps([
|
||||
"create (Connection lost. Trying to reconnect...)",
|
||||
"version_info",
|
||||
"version_info",
|
||||
"close",
|
||||
"create (Connection restored. You are back online.)",
|
||||
]);
|
||||
expect.verifyErrors([
|
||||
`Error: Connection to "/fake_url" couldn't be established or was interrupted`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("will let handlers from the registry handle errors first", async () => {
|
||||
expect.assertions(4);
|
||||
expect.errors(1);
|
||||
const testEnv = await makeMockEnv();
|
||||
testEnv.someValue = 14;
|
||||
errorHandlerRegistry.add("__test_handler__", (env, err, originalError) => {
|
||||
expect(originalError).toBe(error);
|
||||
expect(env.someValue).toBe(14);
|
||||
expect.step("in handler");
|
||||
return true;
|
||||
});
|
||||
const error = new Error();
|
||||
error.name = "boom";
|
||||
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["boom"]);
|
||||
expect.verifySteps(["in handler"]);
|
||||
});
|
||||
|
||||
test("originalError is the root cause of the error chain", async () => {
|
||||
expect.assertions(10);
|
||||
expect.errors(2);
|
||||
await makeMockEnv();
|
||||
const error = new Error();
|
||||
error.name = "boom";
|
||||
errorHandlerRegistry.add("__test_handler__", (env, err, originalError) => {
|
||||
expect(err).toBeInstanceOf(UncaughtPromiseError); // Wrapped by error service
|
||||
expect(err.cause).toBeInstanceOf(OwlError); // Wrapped by owl
|
||||
expect(err.cause.cause).toBe(originalError); // original error
|
||||
expect.step("in handler");
|
||||
return true;
|
||||
});
|
||||
|
||||
class ErrHandler extends Component {
|
||||
static template = xml`<t t-component="props.comp"/>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
onError(async (err) => {
|
||||
Promise.reject(err);
|
||||
await animationFrame();
|
||||
prom.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
class ThrowInSetup extends Component {
|
||||
static template = xml``;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let prom = new Deferred();
|
||||
mountWithCleanup(ErrHandler, { props: { comp: ThrowInSetup } });
|
||||
await prom;
|
||||
expect.verifyErrors([
|
||||
`Error: An error occured in the owl lifecycle (see this Error's "cause" property)`,
|
||||
]);
|
||||
expect.verifySteps(["in handler"]);
|
||||
|
||||
class ThrowInWillStart extends Component {
|
||||
static template = xml``;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
onWillStart(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
prom = new Deferred();
|
||||
mountWithCleanup(ErrHandler, { props: { comp: ThrowInWillStart } });
|
||||
await prom;
|
||||
expect.verifyErrors([`Error: The following error occurred in onWillStart: ""`]);
|
||||
expect.verifySteps(["in handler"]);
|
||||
});
|
||||
|
||||
test("handle uncaught promise errors", async () => {
|
||||
expect.assertions(5);
|
||||
expect.errors(1);
|
||||
class TestError extends Error {}
|
||||
const error = new TestError();
|
||||
error.message = "This is an error test";
|
||||
error.name = "TestError";
|
||||
|
||||
mockService("dialog", {
|
||||
add(dialogClass, props) {
|
||||
expect(dialogClass).toBe(ClientErrorDialog);
|
||||
expect(omit(props, "traceback", "serverHost")).toEqual({
|
||||
name: "UncaughtPromiseError > TestError",
|
||||
message: "Uncaught Promise > This is an error test",
|
||||
});
|
||||
expect(props.traceback).toMatch(/TestError/);
|
||||
expect(props.traceback).toMatch(/This is an error test/);
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["TestError: This is an error test"]);
|
||||
});
|
||||
|
||||
test("handle uncaught client errors", async () => {
|
||||
expect.assertions(4);
|
||||
expect.errors(1);
|
||||
class TestError extends Error {}
|
||||
const error = new TestError();
|
||||
error.message = "This is an error test";
|
||||
error.name = "TestError";
|
||||
|
||||
mockService("dialog", {
|
||||
add(dialogClass, props) {
|
||||
expect(dialogClass).toBe(ClientErrorDialog);
|
||||
expect(props.name).toBe("UncaughtClientError > TestError");
|
||||
expect(props.message).toBe("Uncaught Javascript Error > This is an error test");
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
setTimeout(() => {
|
||||
throw error;
|
||||
});
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["TestError: This is an error test"]);
|
||||
});
|
||||
|
||||
test("don't show dialog for errors in third-party scripts", async () => {
|
||||
expect.errors(1);
|
||||
class TestError extends Error {}
|
||||
const error = new TestError();
|
||||
error.name = "Script error.";
|
||||
|
||||
mockService("dialog", {
|
||||
add(_dialogClass, props) {
|
||||
throw new Error("should not pass here");
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
// Error events from errors in third-party scripts have no colno, no lineno and no filename
|
||||
// because of CORS.
|
||||
await manuallyDispatchProgrammaticEvent(window, "error", { error });
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["Script error."]);
|
||||
});
|
||||
|
||||
test("show dialog for errors in third-party scripts in debug mode", async () => {
|
||||
expect.errors(1);
|
||||
class TestError extends Error {}
|
||||
const error = new TestError();
|
||||
error.name = "Script error.";
|
||||
serverState.debug = "1";
|
||||
|
||||
mockService("dialog", {
|
||||
add(_dialogClass, props) {
|
||||
expect.step("Dialog: " + props.message);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
// Error events from errors in third-party scripts have no colno, no lineno and no filename
|
||||
// because of CORS.
|
||||
await manuallyDispatchProgrammaticEvent(window, "error", { error });
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["Script error."]);
|
||||
expect.verifySteps(["Dialog: Third-Party Script Error"]);
|
||||
});
|
||||
|
||||
test("lazy loaded handlers", async () => {
|
||||
expect.assertions(3);
|
||||
expect.errors(2);
|
||||
await makeMockEnv();
|
||||
|
||||
Promise.reject(new Error("error"));
|
||||
await animationFrame();
|
||||
|
||||
expect.verifyErrors(["Error: error"]);
|
||||
|
||||
errorHandlerRegistry.add("__test_handler__", () => {
|
||||
expect.step("in handler");
|
||||
return true;
|
||||
});
|
||||
|
||||
Promise.reject(new Error("error"));
|
||||
await animationFrame();
|
||||
|
||||
expect.verifyErrors(["Error: error"]);
|
||||
expect.verifySteps(["in handler"]);
|
||||
});
|
||||
|
||||
let unhandledRejectionCb;
|
||||
let errorCb;
|
||||
|
||||
describe("Error Service Logs", () => {
|
||||
beforeEach(() => {
|
||||
patchWithCleanup(browser, {
|
||||
addEventListener: (type, cb) => {
|
||||
if (type === "unhandledrejection") {
|
||||
unhandledRejectionCb = cb;
|
||||
} else if (type === "error") {
|
||||
errorCb = cb;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("logs the traceback of the full error chain for unhandledrejection", async () => {
|
||||
expect.assertions(2);
|
||||
const regexParts = [
|
||||
/^.*This is a wrapper error/,
|
||||
/Caused by:.*This is a second wrapper error/,
|
||||
/Caused by:.*This is the original error/,
|
||||
];
|
||||
const errorRegex = new RegExp(regexParts.map((re) => re.source).join(/[\s\S]*/.source));
|
||||
patchWithCleanup(console, {
|
||||
error(errorMessage) {
|
||||
expect(errorMessage).toMatch(errorRegex);
|
||||
},
|
||||
});
|
||||
|
||||
const error = new Error("This is a wrapper error");
|
||||
error.cause = new Error("This is a second wrapper error");
|
||||
error.cause.cause = new Error("This is the original error");
|
||||
|
||||
await makeMockEnv();
|
||||
const errorEvent = new PromiseRejectionEvent("unhandledrejection", {
|
||||
reason: error,
|
||||
promise: null,
|
||||
cancelable: true,
|
||||
});
|
||||
await unhandledRejectionCb(errorEvent);
|
||||
expect(errorEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
test("logs the traceback of the full error chain for uncaughterror", async () => {
|
||||
expect.assertions(2);
|
||||
const regexParts = [
|
||||
/^.*This is a wrapper error/,
|
||||
/Caused by:.*This is a second wrapper error/,
|
||||
/Caused by:.*This is the original error/,
|
||||
];
|
||||
const errorRegex = new RegExp(regexParts.map((re) => re.source).join(/[\s\S]*/.source));
|
||||
patchWithCleanup(console, {
|
||||
error(errorMessage) {
|
||||
expect(errorMessage).toMatch(errorRegex);
|
||||
},
|
||||
});
|
||||
|
||||
const error = new Error("This is a wrapper error");
|
||||
error.cause = new Error("This is a second wrapper error");
|
||||
error.cause.cause = new Error("This is the original error");
|
||||
|
||||
await makeMockEnv();
|
||||
const errorEvent = new Event("error", {
|
||||
promise: null,
|
||||
cancelable: true,
|
||||
});
|
||||
errorEvent.error = error;
|
||||
errorEvent.filename = "dummy_file.js"; // needed to not be treated as a CORS error
|
||||
await errorCb(errorEvent);
|
||||
expect(errorEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
test("error in handlers while handling an error", async () => {
|
||||
// Scenario: an error occurs at the early stage of the "boot" sequence, error handlers
|
||||
// that are supposed to spawn dialogs are not ready then and will crash.
|
||||
// We assert that *exactly one* error message is logged, that contains the original error's traceback
|
||||
// and an indication that a handler has crashed just for not loosing information.
|
||||
// The crash of the error handler should merely be seen as a consequence of the early stage at which the error occurs.
|
||||
errorHandlerRegistry.add(
|
||||
"__test_handler__",
|
||||
(env, err, originalError) => {
|
||||
throw new Error("Boom in handler");
|
||||
},
|
||||
{ sequence: 0 }
|
||||
);
|
||||
// We want to assert that the error_service code does the preventDefault.
|
||||
patchWithCleanup(console, {
|
||||
error(errorMessage) {
|
||||
expect(errorMessage).toMatch(
|
||||
new RegExp(
|
||||
`^@web/core/error_service: handler "__test_handler__" failed with "Error: Boom in handler" while trying to handle:\nError: Genuine Business Boom.*`
|
||||
)
|
||||
);
|
||||
expect.step("error logged");
|
||||
},
|
||||
});
|
||||
|
||||
await makeMockEnv();
|
||||
let errorEvent = new Event("error", {
|
||||
promise: null,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
errorEvent.error = new Error("Genuine Business Boom");
|
||||
errorEvent.error.annotatedTraceback = "annotated";
|
||||
errorEvent.filename = "dummy_file.js"; // needed to not be treated as a CORS error
|
||||
await errorCb(errorEvent);
|
||||
expect(errorEvent.defaultPrevented).toBe(true);
|
||||
expect.verifySteps(["error logged"]);
|
||||
|
||||
errorEvent = new PromiseRejectionEvent("unhandledrejection", {
|
||||
promise: null,
|
||||
cancelable: true,
|
||||
reason: new Error("Genuine Business Boom"),
|
||||
});
|
||||
await unhandledRejectionCb(errorEvent);
|
||||
expect(errorEvent.defaultPrevented).toBe(true);
|
||||
expect.verifySteps(["error logged"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,449 @@
|
|||
import { beforeEach, expect, test } from "@odoo/hoot";
|
||||
import { click, edit, queryAllTexts, queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import {
|
||||
Country,
|
||||
Partner,
|
||||
Player,
|
||||
Product,
|
||||
Stage,
|
||||
Team,
|
||||
addNewRule,
|
||||
clearNotSupported,
|
||||
clickOnButtonAddBranch,
|
||||
clickOnButtonAddNewRule,
|
||||
clickOnButtonDeleteNode,
|
||||
editValue,
|
||||
getOperatorOptions,
|
||||
getTreeEditorContent,
|
||||
getValueOptions,
|
||||
isNotSupportedPath,
|
||||
openModelFieldSelectorPopover,
|
||||
selectOperator,
|
||||
SELECTORS as treeEditorSELECTORS,
|
||||
} from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
mountWithCleanup,
|
||||
serverState,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { ExpressionEditor } from "@web/core/expression_editor/expression_editor";
|
||||
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
|
||||
const SELECTORS = {
|
||||
...treeEditorSELECTORS,
|
||||
debugArea: ".o_expression_editor_debug_container textarea",
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
async function editExpression(value) {
|
||||
await click(SELECTORS.complexConditionInput);
|
||||
|
||||
await edit(value);
|
||||
await animationFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
async function selectConnector(value) {
|
||||
await contains(`${SELECTORS.connector} .dropdown-toggle`).click();
|
||||
await contains(`.dropdown-menu .dropdown-item:contains(${value})`).click();
|
||||
}
|
||||
|
||||
async function makeExpressionEditor(params = {}) {
|
||||
const fieldFilters = params.fieldFilters;
|
||||
delete params.fieldFilters;
|
||||
const props = { ...params };
|
||||
class Parent extends Component {
|
||||
static components = { ExpressionEditor };
|
||||
static template = xml`<ExpressionEditor t-props="expressionEditorProps"/>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.expressionEditorProps = {
|
||||
expression: "1",
|
||||
...props,
|
||||
resModel: "partner",
|
||||
update: (expression) => {
|
||||
if (props.update) {
|
||||
props.update(expression);
|
||||
}
|
||||
this.expressionEditorProps.expression = expression;
|
||||
this.render();
|
||||
},
|
||||
};
|
||||
this.expressionEditorProps.fields = fieldFilters
|
||||
? pick(Partner._fields, ...fieldFilters)
|
||||
: Partner._fields;
|
||||
}
|
||||
async set(expression) {
|
||||
this.expressionEditorProps.expression = expression;
|
||||
this.render();
|
||||
await animationFrame();
|
||||
}
|
||||
}
|
||||
|
||||
return mountWithCleanup(Parent, { props });
|
||||
}
|
||||
|
||||
defineModels([Partner, Product, Team, Player, Country, Stage]);
|
||||
|
||||
beforeEach(() => {
|
||||
serverState.debug = "1";
|
||||
});
|
||||
|
||||
test("rendering of truthy values", async () => {
|
||||
const toTests = [`True`, `true`, `1`, `-1`, `"a"`];
|
||||
const parent = await makeExpressionEditor();
|
||||
for (const expr of toTests) {
|
||||
await parent.set(expr);
|
||||
expect(getTreeEditorContent()).toEqual([{ level: 0, value: "all" }]);
|
||||
}
|
||||
});
|
||||
|
||||
test("rendering of falsy values", async () => {
|
||||
const toTests = [`False`, `false`, `0`, `""`];
|
||||
const parent = await makeExpressionEditor();
|
||||
for (const expr of toTests) {
|
||||
await parent.set(expr);
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: ["0", "=", "1"], level: 1 },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("rendering of 'expr'", async () => {
|
||||
serverState.debug = "";
|
||||
await makeExpressionEditor({ expression: "expr" });
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: "expr", level: 1 },
|
||||
]);
|
||||
expect(queryOne(SELECTORS.complexConditionInput).readOnly).toBe(true);
|
||||
});
|
||||
|
||||
test("rendering of 'expr' in dev mode", async () => {
|
||||
await makeExpressionEditor({ expression: "expr" });
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: "expr", level: 1 },
|
||||
]);
|
||||
expect(queryOne(SELECTORS.complexConditionInput).readOnly).toBe(false);
|
||||
});
|
||||
|
||||
test("edit a complex condition in dev mode", async () => {
|
||||
await makeExpressionEditor({ expression: "expr" });
|
||||
expect(SELECTORS.condition).toHaveCount(0);
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: "expr", level: 1 },
|
||||
]);
|
||||
await editExpression("uid");
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: "uid", level: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("delete a complex condition", async () => {
|
||||
await makeExpressionEditor({ expression: "expr" });
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: "expr", level: 1 },
|
||||
]);
|
||||
await clickOnButtonDeleteNode();
|
||||
expect(getTreeEditorContent()).toEqual([{ value: "all", level: 0 }]);
|
||||
});
|
||||
|
||||
test("copy a complex condition", async () => {
|
||||
await makeExpressionEditor({ expression: "expr" });
|
||||
expect(SELECTORS.condition).toHaveCount(0);
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: "expr", level: 1 },
|
||||
]);
|
||||
await clickOnButtonAddNewRule();
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: "expr", level: 1 },
|
||||
{ value: "expr", level: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("change path, operator and value", async () => {
|
||||
serverState.debug = "";
|
||||
await makeExpressionEditor({ expression: `bar != "blabla"` });
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "all" },
|
||||
{ level: 1, value: ["Bar", "is not", "blabla"] },
|
||||
]);
|
||||
const tree = getTreeEditorContent({ node: true });
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(".o_model_field_selector_popover_item_name:eq(5)").click();
|
||||
await selectOperator("not in", 0, tree[1].node);
|
||||
await editValue(["Doku", "Lukaku", "KDB"]);
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "all" },
|
||||
{ level: 1, value: ["Foo", "is not in", "Doku,Lukaku,KDB"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("create a new branch from a complex condition control panel", async () => {
|
||||
await makeExpressionEditor({ expression: "expr" });
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: "expr", level: 1 },
|
||||
]);
|
||||
await clickOnButtonAddBranch();
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "all" },
|
||||
{ level: 1, value: "expr" },
|
||||
{ level: 1, value: "any" },
|
||||
{ level: 2, value: ["Id", "=", "1"] },
|
||||
{ level: 2, value: ["Id", "=", "1"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rendering of a valid fieldName in fields", async () => {
|
||||
const parent = await makeExpressionEditor({ fieldFilters: ["foo"] });
|
||||
|
||||
const toTests = [
|
||||
{ expr: `foo`, condition: ["Foo", "is set"] },
|
||||
{ expr: `foo == "a"`, condition: ["Foo", "=", "a"] },
|
||||
{ expr: `foo != "a"`, condition: ["Foo", "!=", "a"] },
|
||||
// { expr: `foo is "a"`, complexCondition: `foo is "a"` },
|
||||
// { expr: `foo is not "a"`, complexCondition: `foo is not "a"` },
|
||||
{ expr: `not foo`, condition: ["Foo", "is not set"] },
|
||||
{ expr: `foo + "a"`, complexCondition: `foo + "a"` },
|
||||
];
|
||||
|
||||
for (const { expr, condition, complexCondition } of toTests) {
|
||||
await parent.set(expr);
|
||||
const tree = getTreeEditorContent();
|
||||
if (condition) {
|
||||
expect(tree).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: condition, level: 1 },
|
||||
]);
|
||||
} else if (complexCondition) {
|
||||
expect(tree).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: complexCondition, level: 1 },
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("rendering of simple conditions", async () => {
|
||||
Partner._fields.bar = fields.Char();
|
||||
Partner._records = [];
|
||||
const parent = await makeExpressionEditor({ fieldFilters: ["foo", "bar"] });
|
||||
|
||||
const toTests = [
|
||||
{ expr: `bar == "a"`, condition: ["Bar", "=", "a"] },
|
||||
{ expr: `foo == expr`, condition: ["Foo", "=", "expr"] },
|
||||
{ expr: `"a" == foo`, condition: ["Foo", "=", "a"] },
|
||||
{ expr: `expr == foo`, condition: ["Foo", "=", "expr"] },
|
||||
{ expr: `foo == bar`, complexCondition: `foo == bar` },
|
||||
{ expr: `"a" == "b"`, complexCondition: `"a" == "b"` },
|
||||
{ expr: `expr1 == expr2`, complexCondition: `expr1 == expr2` },
|
||||
|
||||
{ expr: `foo < "a"`, condition: ["Foo", "<", "a"] },
|
||||
{ expr: `foo < expr`, condition: ["Foo", "<", "expr"] },
|
||||
{ expr: `"a" < foo`, condition: ["Foo", ">", "a"] },
|
||||
{ expr: `expr < foo`, condition: ["Foo", ">", "expr"] },
|
||||
{ expr: `foo < bar`, complexCondition: `foo < bar` },
|
||||
{ expr: `"a" < "b"`, complexCondition: `"a" < "b"` },
|
||||
{ expr: `expr1 < expr2`, complexCondition: `expr1 < expr2` },
|
||||
|
||||
{ expr: `foo in ["a"]`, condition: ["Foo", "is in", "a"] },
|
||||
{ expr: `foo in [expr]`, condition: ["Foo", "is in", "expr"] },
|
||||
{ expr: `"a" in foo`, complexCondition: `"a" in foo` },
|
||||
{ expr: `expr in foo`, complexCondition: `expr in foo` },
|
||||
{ expr: `foo in bar`, complexCondition: `foo in bar` },
|
||||
{ expr: `"a" in "b"`, complexCondition: `"a" in "b"` },
|
||||
{ expr: `expr1 in expr2`, complexCondition: `expr1 in expr2` },
|
||||
];
|
||||
|
||||
for (const { expr, condition, complexCondition } of toTests) {
|
||||
await parent.set(expr);
|
||||
const tree = getTreeEditorContent();
|
||||
if (condition) {
|
||||
expect(tree).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: condition, level: 1 },
|
||||
]);
|
||||
} else if (complexCondition) {
|
||||
expect(tree).toEqual([
|
||||
{ value: "all", level: 0 },
|
||||
{ value: complexCondition, level: 1 },
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("rendering of connectors", async () => {
|
||||
await makeExpressionEditor({ expression: `expr and foo == "abc" or not bar` });
|
||||
expect(queryAllTexts(`${SELECTORS.connector} .dropdown-toggle`)).toEqual(["any", "all"]);
|
||||
const tree = getTreeEditorContent();
|
||||
expect(tree).toEqual([
|
||||
{ level: 0, value: "any" },
|
||||
{ level: 1, value: "all" },
|
||||
{ level: 2, value: "expr" },
|
||||
{ level: 2, value: ["Foo", "=", "abc"] },
|
||||
{ level: 1, value: ["Bar", "is", "not set"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rendering of connectors (2)", async () => {
|
||||
await makeExpressionEditor({
|
||||
expression: `not (expr or foo == "abc")`,
|
||||
update(expression) {
|
||||
expect.step(expression);
|
||||
},
|
||||
});
|
||||
expect(`${SELECTORS.connector} .dropdown-toggle:only`).toHaveText("none");
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "none" },
|
||||
{ level: 1, value: "expr" },
|
||||
{ level: 1, value: ["Foo", "=", "abc"] },
|
||||
]);
|
||||
expect.verifySteps([]);
|
||||
expect(queryOne(SELECTORS.debugArea)).toHaveValue(`not (expr or foo == "abc")`);
|
||||
|
||||
await selectConnector("all");
|
||||
expect(`${SELECTORS.connector} .dropdown-toggle:only`).toHaveText("all");
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "all" },
|
||||
{ level: 1, value: "expr" },
|
||||
{ level: 1, value: ["Foo", "=", "abc"] },
|
||||
]);
|
||||
expect.verifySteps([`expr and foo == "abc"`]);
|
||||
expect(queryOne(SELECTORS.debugArea)).toHaveValue(`expr and foo == "abc"`);
|
||||
});
|
||||
|
||||
test("rendering of if else", async () => {
|
||||
await makeExpressionEditor({ expression: `True if False else False` });
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "any" },
|
||||
{ level: 1, value: "all" },
|
||||
{ level: 2, value: ["0", "=", "1"] },
|
||||
{ level: 2, value: ["1", "=", "1"] },
|
||||
{ level: 1, value: "all" },
|
||||
{ level: 2, value: ["1", "=", "1"] },
|
||||
{ level: 2, value: ["0", "=", "1"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("check condition by default when creating a new rule", async () => {
|
||||
serverState.debug = "";
|
||||
Partner._fields.country_id = fields.Char({ string: "Country ID" });
|
||||
await makeExpressionEditor({ expression: "expr" });
|
||||
await contains("a[role='button']").click();
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "all" },
|
||||
{ level: 1, value: "expr" },
|
||||
{ level: 1, value: ["Country ID", "=", ""] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("allow selection of boolean field", async () => {
|
||||
await makeExpressionEditor({ expression: "id" });
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "all" },
|
||||
{ level: 1, value: ["Id", "is set"] },
|
||||
]);
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(".o_model_field_selector_popover_item_name").click();
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "all" },
|
||||
{ level: 1, value: ["Bar", "is", "set"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("render false and true leaves", async () => {
|
||||
await makeExpressionEditor({ expression: `False and True` });
|
||||
expect(getOperatorOptions()).toEqual(["="]);
|
||||
expect(getValueOptions()).toEqual(["1"]);
|
||||
expect(getOperatorOptions(-1)).toEqual(["="]);
|
||||
expect(getValueOptions(-1)).toEqual(["1"]);
|
||||
});
|
||||
|
||||
test("no field of type properties in model field selector", async () => {
|
||||
serverState.debug = "";
|
||||
Partner._fields.properties = fields.Properties({
|
||||
string: "Properties",
|
||||
definition_record: "product_id",
|
||||
definition_record_field: "definitions",
|
||||
});
|
||||
Product._fields.definitions = fields.PropertiesDefinition();
|
||||
await makeExpressionEditor({
|
||||
expression: `properties`,
|
||||
fieldFilters: ["foo", "bar", "properties"],
|
||||
update(expression) {
|
||||
expect.step(expression);
|
||||
},
|
||||
});
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "all" },
|
||||
{ level: 1, value: ["Properties", "is set"] },
|
||||
]);
|
||||
expect(isNotSupportedPath()).toBe(true);
|
||||
await clearNotSupported();
|
||||
expect.verifySteps([`foo == ""`]);
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(queryAllTexts(".o_model_field_selector_popover_item_name")).toEqual(["Bar", "Foo"]);
|
||||
});
|
||||
|
||||
test("no special fields in fields", async () => {
|
||||
serverState.debug = "";
|
||||
await makeExpressionEditor({
|
||||
expression: `bar`,
|
||||
fieldFilters: ["foo", "bar", "properties"],
|
||||
update(expression) {
|
||||
expect.step(expression);
|
||||
},
|
||||
});
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "all" },
|
||||
{ level: 1, value: ["Bar", "is not", "not set"] },
|
||||
]);
|
||||
await addNewRule();
|
||||
expect(getTreeEditorContent()).toEqual([
|
||||
{ level: 0, value: "all" },
|
||||
{ level: 1, value: ["Bar", "is not", "not set"] },
|
||||
{ level: 1, value: ["Foo", "=", ""] },
|
||||
]);
|
||||
expect.verifySteps([`bar and foo == ""`]);
|
||||
});
|
||||
|
||||
test("between operator", async () => {
|
||||
await makeExpressionEditor({
|
||||
expression: `id == 1`,
|
||||
update(expression) {
|
||||
expect.step(expression);
|
||||
},
|
||||
});
|
||||
expect(getOperatorOptions()).toEqual([
|
||||
"=",
|
||||
"!=",
|
||||
">",
|
||||
">=",
|
||||
"<",
|
||||
"<=",
|
||||
"is between",
|
||||
"is set",
|
||||
"is not set",
|
||||
]);
|
||||
expect.verifySteps([]);
|
||||
await selectOperator("between");
|
||||
expect.verifySteps([`id >= 1 and id <= 1`]);
|
||||
});
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { expect, test, describe } from "@odoo/hoot";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
mountWithCleanup,
|
||||
makeDialogMockEnv,
|
||||
mockService,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import {
|
||||
Country,
|
||||
Partner,
|
||||
Player,
|
||||
Product,
|
||||
Stage,
|
||||
Team,
|
||||
getTreeEditorContent,
|
||||
} from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
|
||||
|
||||
import { ExpressionEditorDialog } from "@web/core/expression_editor_dialog/expression_editor_dialog";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
async function makeExpressionEditorDialog(params = {}) {
|
||||
const props = { ...params };
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { ExpressionEditorDialog };
|
||||
static template = xml`<ExpressionEditorDialog t-props="expressionEditorProps"/>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.expressionEditorProps = {
|
||||
expression: "1",
|
||||
close: () => {},
|
||||
onConfirm: () => {},
|
||||
...props,
|
||||
resModel: "partner",
|
||||
};
|
||||
this.expressionEditorProps.fields = Partner._fields;
|
||||
}
|
||||
async set(expression) {
|
||||
this.expressionEditorProps.expression = expression;
|
||||
this.render();
|
||||
await animationFrame();
|
||||
}
|
||||
}
|
||||
const env = await makeDialogMockEnv();
|
||||
return mountWithCleanup(Parent, { env, props });
|
||||
}
|
||||
|
||||
defineModels([Partner, Product, Team, Player, Country, Stage]);
|
||||
|
||||
test("expr well sent, onConfirm and onClose", async () => {
|
||||
const expression = `foo == 'batestr' and bar == True`;
|
||||
await makeExpressionEditorDialog({
|
||||
expression,
|
||||
close: () => {
|
||||
expect.step("close");
|
||||
},
|
||||
onConfirm: (result) => {
|
||||
expect.step(result);
|
||||
},
|
||||
});
|
||||
expect(".o_technical_modal").toHaveCount(1);
|
||||
await contains(".o_dialog footer button").click();
|
||||
expect.verifySteps([expression, "close"]);
|
||||
});
|
||||
|
||||
test("expr well sent but wrong, so notification when onConfirm", async () => {
|
||||
const expression = `foo == 'bar' and bar = True`;
|
||||
mockService("notification", {
|
||||
add(message, options) {
|
||||
expect(message).toBe("Expression is invalid. Please correct it");
|
||||
expect(options).toEqual({ type: "danger" });
|
||||
expect.step("notification");
|
||||
},
|
||||
});
|
||||
await makeExpressionEditorDialog({
|
||||
expression,
|
||||
});
|
||||
expect(".o_technical_modal").toHaveCount(1);
|
||||
await contains(".modal-footer button").click();
|
||||
await contains(".modal-body button").click();
|
||||
expect(getTreeEditorContent()).toEqual([{ level: 0, value: "all" }]);
|
||||
expect.verifySteps(["notification"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
defineModels,
|
||||
fields,
|
||||
getService,
|
||||
makeMockEnv,
|
||||
MockServer,
|
||||
models,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { Deferred, animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
function getModelInfo(resModel) {
|
||||
return {
|
||||
resModel: resModel._name,
|
||||
fieldDefs: JSON.parse(JSON.stringify(resModel._fields)),
|
||||
};
|
||||
}
|
||||
|
||||
function getDefinitions() {
|
||||
const fieldDefs = {};
|
||||
for (const record of MockServer.env["species"]) {
|
||||
for (const definition of record.definitions) {
|
||||
fieldDefs[definition.name] = {
|
||||
is_property: true,
|
||||
searchable: true,
|
||||
record_name: record.display_name,
|
||||
record_id: record.id,
|
||||
...definition,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { resModel: "*", fieldDefs };
|
||||
}
|
||||
|
||||
class Tortoise extends models.Model {
|
||||
name = fields.Char();
|
||||
age = fields.Integer();
|
||||
location_id = fields.Many2one({ string: "Location", relation: "location" });
|
||||
species = fields.Many2one({ relation: "species" });
|
||||
property_field = fields.Properties({
|
||||
string: "Properties",
|
||||
definition_record: "species",
|
||||
definition_record_field: "definitions",
|
||||
});
|
||||
}
|
||||
|
||||
class Location extends models.Model {
|
||||
name = fields.Char();
|
||||
tortoise_ids = fields.One2many({ string: "Turtles", relation: "tortoise" });
|
||||
}
|
||||
|
||||
class Species extends models.Model {
|
||||
name = fields.Char();
|
||||
definitions = fields.PropertiesDefinition();
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "Galápagos tortoise",
|
||||
definitions: [
|
||||
{
|
||||
name: "galapagos_lifespans",
|
||||
string: "Lifespans",
|
||||
type: "integer",
|
||||
},
|
||||
{
|
||||
name: "location_ids",
|
||||
string: "Locations",
|
||||
type: "many2many",
|
||||
relation: "location",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
display_name: "Aldabra giant tortoise",
|
||||
definitions: [
|
||||
{ name: "aldabra_lifespans", string: "Lifespans", type: "integer" },
|
||||
{ name: "color", string: "Color", type: "char" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
defineModels([Tortoise, Location, Species]);
|
||||
|
||||
test("loadPath", async () => {
|
||||
await makeMockEnv();
|
||||
|
||||
const toTest = [
|
||||
{
|
||||
resModel: "tortoise",
|
||||
path: "*",
|
||||
expectedResult: {
|
||||
names: ["*"],
|
||||
modelsInfo: [getModelInfo(Tortoise)],
|
||||
},
|
||||
},
|
||||
{
|
||||
resModel: "tortoise",
|
||||
path: "*.a",
|
||||
expectedResult: {
|
||||
isInvalid: "path",
|
||||
names: ["*", "a"],
|
||||
modelsInfo: [getModelInfo(Tortoise)],
|
||||
},
|
||||
},
|
||||
{
|
||||
resModel: "tortoise",
|
||||
path: "location_id.*",
|
||||
expectedResult: {
|
||||
names: ["location_id", "*"],
|
||||
modelsInfo: [getModelInfo(Tortoise), getModelInfo(Location)],
|
||||
},
|
||||
},
|
||||
{
|
||||
resModel: "tortoise",
|
||||
path: "age",
|
||||
expectedResult: {
|
||||
names: ["age"],
|
||||
modelsInfo: [getModelInfo(Tortoise)],
|
||||
},
|
||||
},
|
||||
{
|
||||
resModel: "tortoise",
|
||||
path: "location_id",
|
||||
expectedResult: {
|
||||
names: ["location_id"],
|
||||
modelsInfo: [getModelInfo(Tortoise)],
|
||||
},
|
||||
},
|
||||
{
|
||||
resModel: "tortoise",
|
||||
path: "location_id.tortoise_ids",
|
||||
expectedResult: {
|
||||
names: ["location_id", "tortoise_ids"],
|
||||
modelsInfo: [getModelInfo(Tortoise), getModelInfo(Location)],
|
||||
},
|
||||
},
|
||||
{
|
||||
resModel: "tortoise",
|
||||
path: "location_id.tortoise_ids.age",
|
||||
expectedResult: {
|
||||
names: ["location_id", "tortoise_ids", "age"],
|
||||
modelsInfo: [
|
||||
getModelInfo(Tortoise),
|
||||
getModelInfo(Location),
|
||||
getModelInfo(Tortoise),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
resModel: "tortoise",
|
||||
path: "location_id.tortoise_ids.age",
|
||||
expectedResult: {
|
||||
names: ["location_id", "tortoise_ids", "age"],
|
||||
modelsInfo: [
|
||||
getModelInfo(Tortoise),
|
||||
getModelInfo(Location),
|
||||
getModelInfo(Tortoise),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
resModel: "tortoise",
|
||||
path: "property_field",
|
||||
expectedResult: {
|
||||
names: ["property_field"],
|
||||
modelsInfo: [getModelInfo(Tortoise)],
|
||||
},
|
||||
},
|
||||
{
|
||||
resModel: "tortoise",
|
||||
path: "property_field.galapagos_lifespans",
|
||||
expectedResult: {
|
||||
names: ["property_field", "galapagos_lifespans"],
|
||||
modelsInfo: [getModelInfo(Tortoise), getDefinitions()],
|
||||
},
|
||||
},
|
||||
{
|
||||
resModel: "tortoise",
|
||||
path: "property_field.location_ids.tortoise_ids",
|
||||
expectedResult: {
|
||||
isInvalid: "path",
|
||||
names: ["property_field", "location_ids", "tortoise_ids"],
|
||||
modelsInfo: [getModelInfo(Tortoise), getDefinitions()],
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const { resModel, path, expectedResult } of toTest) {
|
||||
const result = await getService("field").loadPath(resModel, path);
|
||||
expect(result).toEqual(expectedResult);
|
||||
}
|
||||
|
||||
const errorToTest = [
|
||||
{ resModel: "notAModel" },
|
||||
{ resModel: "tortoise", path: {} },
|
||||
{ resModel: "tortoise", path: "" },
|
||||
];
|
||||
|
||||
for (const { resModel, path } of errorToTest) {
|
||||
try {
|
||||
await getService("field").loadPath(resModel, path);
|
||||
} catch {
|
||||
expect.step("error");
|
||||
}
|
||||
}
|
||||
expect.verifySteps(errorToTest.map(() => "error"));
|
||||
});
|
||||
|
||||
test("store loadFields calls in cache in success", async () => {
|
||||
onRpc("fields_get", () => {
|
||||
expect.step("fields_get");
|
||||
});
|
||||
|
||||
await makeMockEnv();
|
||||
|
||||
await getService("field").loadFields("tortoise");
|
||||
await getService("field").loadFields("tortoise");
|
||||
|
||||
expect.verifySteps(["fields_get"]);
|
||||
});
|
||||
|
||||
test("does not store loadFields calls in cache when failed", async () => {
|
||||
onRpc("fields_get", () => {
|
||||
expect.step("fields_get");
|
||||
throw "my little error";
|
||||
});
|
||||
|
||||
await makeMockEnv();
|
||||
await expect(getService("field").loadFields("take.five")).rejects.toThrow(/my little error/);
|
||||
await expect(getService("field").loadFields("take.five")).rejects.toThrow(/my little error/);
|
||||
|
||||
expect.verifySteps(["fields_get", "fields_get"]);
|
||||
});
|
||||
|
||||
test("async method loadFields is protected", async () => {
|
||||
let callFieldService;
|
||||
class Child extends Component {
|
||||
static template = xml`
|
||||
<div class="o_child_component" />
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.fieldService = useService("field");
|
||||
callFieldService = async () => {
|
||||
expect.step("loadFields called");
|
||||
await this.fieldService.loadFields("tortoise");
|
||||
expect.step("loadFields result get");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { Child };
|
||||
static template = xml`
|
||||
<t t-if="state.displayChild">
|
||||
<Child />
|
||||
</t>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ displayChild: true });
|
||||
}
|
||||
}
|
||||
|
||||
const def = new Deferred();
|
||||
onRpc(async () => {
|
||||
await def;
|
||||
});
|
||||
const parent = await mountWithCleanup(Parent);
|
||||
|
||||
expect(".o_child_component").toHaveCount(1);
|
||||
|
||||
callFieldService();
|
||||
expect.verifySteps(["loadFields called"]);
|
||||
|
||||
parent.state.displayChild = false;
|
||||
await animationFrame();
|
||||
|
||||
def.resolve();
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
try {
|
||||
await callFieldService();
|
||||
} catch (e) {
|
||||
expect.step(e.message);
|
||||
}
|
||||
|
||||
expect.verifySteps(["loadFields called", "Component is destroyed"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { beforeEach, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
contains,
|
||||
mockService,
|
||||
mountWithCleanup,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { setInputFiles } from "@odoo/hoot-dom";
|
||||
import { Deferred, animationFrame } from "@odoo/hoot-mock";
|
||||
import { FileInput } from "@web/core/file_input/file_input";
|
||||
import { session } from "@web/session";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function createFileInput({ mockPost, mockAdd, props }) {
|
||||
mockService("notification", {
|
||||
add: mockAdd || (() => {}),
|
||||
});
|
||||
mockService("http", {
|
||||
post: mockPost || (() => {}),
|
||||
});
|
||||
await mountWithCleanup(FileInput, { props });
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
patchWithCleanup(odoo, { csrf_token: "dummy" });
|
||||
});
|
||||
|
||||
test("Upload a file: default props", async () => {
|
||||
expect.assertions(5);
|
||||
|
||||
await createFileInput({
|
||||
mockPost: (route, params) => {
|
||||
expect(params).toEqual({
|
||||
csrf_token: "dummy",
|
||||
ufile: [],
|
||||
});
|
||||
expect.step(route);
|
||||
return "[]";
|
||||
},
|
||||
props: {},
|
||||
});
|
||||
|
||||
expect(".o_file_input").toHaveText("Choose File", {
|
||||
message: "File input total text should match its given inner element's text",
|
||||
});
|
||||
expect(".o_file_input input").toHaveAttribute("accept", "*", {
|
||||
message: "Input should accept all files by default",
|
||||
});
|
||||
|
||||
await contains(".o_file_input input", { visible: false }).click();
|
||||
await setInputFiles([]);
|
||||
|
||||
expect(".o_file_input input").not.toHaveAttribute("multiple", null, {
|
||||
message: "'multiple' attribute should not be set",
|
||||
});
|
||||
expect.verifySteps(["/web/binary/upload_attachment"]);
|
||||
});
|
||||
|
||||
test("Upload a file: custom attachment", async () => {
|
||||
expect.assertions(5);
|
||||
|
||||
await createFileInput({
|
||||
props: {
|
||||
acceptedFileExtensions: ".png",
|
||||
multiUpload: true,
|
||||
resId: 5,
|
||||
resModel: "res.model",
|
||||
route: "/web/binary/upload",
|
||||
onUpload(files) {
|
||||
expect(files).toHaveLength(0, {
|
||||
message: "'files' property should be an empty array",
|
||||
});
|
||||
},
|
||||
},
|
||||
mockPost: (route, params) => {
|
||||
expect(params).toEqual({
|
||||
id: 5,
|
||||
model: "res.model",
|
||||
csrf_token: "dummy",
|
||||
ufile: [],
|
||||
});
|
||||
expect.step(route);
|
||||
return "[]";
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_file_input input").toHaveAttribute("accept", ".png", {
|
||||
message: "Input should now only accept pngs",
|
||||
});
|
||||
|
||||
await contains(".o_file_input input", { visible: false }).click();
|
||||
await setInputFiles([]);
|
||||
|
||||
expect(".o_file_input input").toHaveAttribute("multiple", null, {
|
||||
message: "'multiple' attribute should be set",
|
||||
});
|
||||
|
||||
expect.verifySteps(["/web/binary/upload"]);
|
||||
});
|
||||
|
||||
test("Hidden file input", async () => {
|
||||
await createFileInput({
|
||||
props: { hidden: true },
|
||||
});
|
||||
|
||||
expect(".o_file_input").not.toBeVisible();
|
||||
});
|
||||
|
||||
test("uploading the same file twice triggers the onChange twice", async () => {
|
||||
await createFileInput({
|
||||
props: {
|
||||
onUpload(files) {
|
||||
expect.step(files[0].name);
|
||||
},
|
||||
},
|
||||
mockPost: (_, params) => {
|
||||
return JSON.stringify([{ name: params.ufile[0].name }]);
|
||||
},
|
||||
});
|
||||
|
||||
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
|
||||
await contains(".o_file_input input", { visible: false }).click();
|
||||
await setInputFiles([file]);
|
||||
await animationFrame();
|
||||
expect.verifySteps(["fake_file.txt"]);
|
||||
|
||||
await contains(".o_file_input input", { visible: false }).click();
|
||||
await setInputFiles([file]);
|
||||
await animationFrame();
|
||||
expect.verifySteps(["fake_file.txt"]);
|
||||
});
|
||||
|
||||
test("uploading a file that is too heavy will send a notification", async () => {
|
||||
patchWithCleanup(session, { max_file_upload_size: 2 });
|
||||
await createFileInput({
|
||||
props: {
|
||||
onUpload(files) {
|
||||
// This code should be unreachable in this case
|
||||
expect.step(files[0].name);
|
||||
},
|
||||
},
|
||||
mockPost: (_, params) => {
|
||||
return JSON.stringify([{ name: params.ufile[0].name }]);
|
||||
},
|
||||
mockAdd: (message) => {
|
||||
expect.step("notification");
|
||||
// Message is a bit weird because values (2 and 4 bytes) are simplified to 2 decimals in regards to megabytes
|
||||
expect(message).toBe(
|
||||
"The selected file (4B) is larger than the maximum allowed file size (2B)."
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
|
||||
await contains(".o_file_input input", { visible: false }).click();
|
||||
await setInputFiles([file]);
|
||||
await animationFrame();
|
||||
expect.verifySteps(["notification"]);
|
||||
});
|
||||
|
||||
test("Upload button is disabled if attachment upload is not finished", async () => {
|
||||
const uploadedPromise = new Deferred();
|
||||
await createFileInput({
|
||||
mockPost: async (route) => {
|
||||
if (route === "/web/binary/upload_attachment") {
|
||||
await uploadedPromise;
|
||||
}
|
||||
return "[]";
|
||||
},
|
||||
props: {},
|
||||
});
|
||||
//enable button
|
||||
await contains(".o_file_input input", { visible: false }).click();
|
||||
await setInputFiles([]);
|
||||
await animationFrame();
|
||||
|
||||
//disable button
|
||||
expect(".o_file_input input").not.toBeEnabled({
|
||||
message: "the upload button should be disabled on upload",
|
||||
});
|
||||
|
||||
uploadedPromise.resolve();
|
||||
await animationFrame();
|
||||
expect(".o_file_input input").toBeEnabled({
|
||||
message: "the upload button should be enabled for upload",
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
|
||||
import { FileModel } from "@web/core/file_viewer/file_model";
|
||||
|
||||
test("url query params of FileModel returns proper params", () => {
|
||||
const attachmentData = {
|
||||
access_token: "4b52e31e-a155-4598-8d15-538f64f0fb7b",
|
||||
checksum: "f6a9d2bcbb34ce90a73785d8c8d1b82e5cdf0b5b",
|
||||
extension: "jpg",
|
||||
name: "test.jpg",
|
||||
mimetype: "image/jpeg",
|
||||
};
|
||||
const expectedQueryParams = {
|
||||
access_token: "4b52e31e-a155-4598-8d15-538f64f0fb7b",
|
||||
filename: "test.jpg",
|
||||
unique: "f6a9d2bcbb34ce90a73785d8c8d1b82e5cdf0b5b",
|
||||
};
|
||||
const fileModel = Object.assign(new FileModel(), attachmentData);
|
||||
expect(fileModel.urlQueryParams).toEqual(expectedQueryParams);
|
||||
});
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
contains,
|
||||
getService,
|
||||
mockService,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { Deferred, animationFrame } from "@odoo/hoot-mock";
|
||||
import { FileUploadProgressContainer } from "@web/core/file_upload/file_upload_progress_container";
|
||||
import { FileUploadProgressRecord } from "@web/core/file_upload/file_upload_progress_record";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
|
||||
class FileUploadProgressTestRecord extends FileUploadProgressRecord {
|
||||
static template = xml`
|
||||
<t t-set="progressTexts" t-value="getProgressTexts()"/>
|
||||
<div class="file_upload">
|
||||
<div class="file_upload_progress_text_left" t-esc="progressTexts.left"/>
|
||||
<div class="file_upload_progress_text_right" t-esc="progressTexts.right"/>
|
||||
<FileUploadProgressBar fileUpload="props.fileUpload"/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
class Parent extends Component {
|
||||
static components = {
|
||||
FileUploadProgressContainer,
|
||||
};
|
||||
static template = xml`
|
||||
<div class="parent">
|
||||
<FileUploadProgressContainer fileUploads="fileUploadService.uploads" shouldDisplay="props.shouldDisplay" Component="FileUploadProgressTestRecord"/>
|
||||
</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.fileUploadService = useService("file_upload");
|
||||
this.FileUploadProgressTestRecord = FileUploadProgressTestRecord;
|
||||
}
|
||||
}
|
||||
|
||||
onRpc("/test/", () => new Deferred());
|
||||
|
||||
test("can be rendered", async () => {
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".parent").toHaveCount(1);
|
||||
expect(".file_upload").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("upload renders new component(s)", async () => {
|
||||
await mountWithCleanup(Parent);
|
||||
const fileUploadService = await getService("file_upload");
|
||||
fileUploadService.upload("/test/", []);
|
||||
await animationFrame();
|
||||
expect(".file_upload").toHaveCount(1);
|
||||
fileUploadService.upload("/test/", []);
|
||||
await animationFrame();
|
||||
expect(".file_upload").toHaveCount(2);
|
||||
});
|
||||
|
||||
test("upload end removes component", async () => {
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
const fileUploadService = await getService("file_upload");
|
||||
fileUploadService.upload("/test/", []);
|
||||
await animationFrame();
|
||||
fileUploadService.uploads[1].xhr.dispatchEvent(new Event("load"));
|
||||
await animationFrame();
|
||||
expect(".file_upload").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("upload error removes component", async () => {
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
const fileUploadService = await getService("file_upload");
|
||||
fileUploadService.upload("/test/", []);
|
||||
await animationFrame();
|
||||
fileUploadService.uploads[1].xhr.dispatchEvent(new Event("error"));
|
||||
await animationFrame();
|
||||
expect(".file_upload").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("upload abort removes component", async () => {
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
const fileUploadService = await getService("file_upload");
|
||||
fileUploadService.upload("/test/", []);
|
||||
await animationFrame();
|
||||
fileUploadService.uploads[1].xhr.dispatchEvent(new Event("abort"));
|
||||
await animationFrame();
|
||||
expect(".file_upload").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("upload can be aborted by clicking on cross", async () => {
|
||||
mockService("dialog", {
|
||||
add() {
|
||||
fileUploadService.uploads[1].xhr.dispatchEvent(new Event("abort"));
|
||||
},
|
||||
});
|
||||
await mountWithCleanup(Parent);
|
||||
const fileUploadService = await getService("file_upload");
|
||||
fileUploadService.upload("/test/", []);
|
||||
await animationFrame();
|
||||
await contains(".o-file-upload-progress-bar-abort", { visible: false }).click();
|
||||
await animationFrame();
|
||||
expect(".file_upload").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("upload updates on progress", async () => {
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
const fileUploadService = await getService("file_upload");
|
||||
fileUploadService.upload("/test/", []);
|
||||
await animationFrame();
|
||||
|
||||
const progressEvent = new Event("progress", { bubbles: true });
|
||||
progressEvent.loaded = 250000000;
|
||||
progressEvent.total = 500000000;
|
||||
fileUploadService.uploads[1].xhr.upload.dispatchEvent(progressEvent);
|
||||
await animationFrame();
|
||||
expect(".file_upload_progress_text_left").toHaveText("Uploading... (50%)");
|
||||
progressEvent.loaded = 350000000;
|
||||
fileUploadService.uploads[1].xhr.upload.dispatchEvent(progressEvent);
|
||||
await animationFrame();
|
||||
expect(".file_upload_progress_text_right").toHaveText("(350/500MB)");
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,115 @@
|
|||
import { expect, getFixture, test } from "@odoo/hoot";
|
||||
import { animationFrame, mockFetch } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import {
|
||||
contains,
|
||||
makeMockEnv,
|
||||
mountWithCleanup,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { InstallScopedApp } from "@web/core/install_scoped_app/install_scoped_app";
|
||||
|
||||
const mountManifestLink = (href) => {
|
||||
const fixture = getFixture();
|
||||
const manifestLink = document.createElement("link");
|
||||
manifestLink.rel = "manifest";
|
||||
manifestLink.href = href;
|
||||
fixture.append(manifestLink);
|
||||
};
|
||||
|
||||
test("Installation page displays the app info correctly", async () => {
|
||||
const beforeInstallPromptEvent = new CustomEvent("beforeinstallprompt");
|
||||
beforeInstallPromptEvent.preventDefault = () => {};
|
||||
beforeInstallPromptEvent.prompt = async () => ({ outcome: "accepted" });
|
||||
browser.BeforeInstallPromptEvent = beforeInstallPromptEvent;
|
||||
await makeMockEnv();
|
||||
patchWithCleanup(browser.location, {
|
||||
replace: (url) => {
|
||||
expect(url.searchParams.get("app_name")).toBe("%3COtto%26", {
|
||||
message: "ask to redirect with updated searchParams",
|
||||
});
|
||||
expect.step("URL replace");
|
||||
},
|
||||
});
|
||||
mountManifestLink("/web/manifest.scoped_app_manifest");
|
||||
mockFetch((route) => {
|
||||
expect.step(route);
|
||||
return {
|
||||
icons: [
|
||||
{
|
||||
src: "/fake_image_src",
|
||||
sizes: "any",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
name: "My App",
|
||||
scope: "/scoped_app/myApp",
|
||||
start_url: "/scoped_app/myApp",
|
||||
};
|
||||
});
|
||||
|
||||
class Parent extends Component {
|
||||
static props = ["*"];
|
||||
static components = { InstallScopedApp };
|
||||
static template = xml`<InstallScopedApp/>`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect.verifySteps(["/web/manifest.scoped_app_manifest"]);
|
||||
await animationFrame();
|
||||
expect(".o_install_scoped_app").toHaveCount(1);
|
||||
expect(".o_install_scoped_app h1").toHaveText("My App");
|
||||
expect(".o_install_scoped_app img").toHaveAttribute("data-src", "/fake_image_src");
|
||||
expect(".fa-pencil").toHaveCount(0);
|
||||
expect("button.btn-primary").toHaveCount(0);
|
||||
expect("div.bg-info").toHaveCount(1);
|
||||
expect("div.bg-info").toHaveText("You can install the app from the browser menu");
|
||||
browser.dispatchEvent(beforeInstallPromptEvent);
|
||||
await animationFrame();
|
||||
expect(".fa-pencil").toHaveCount(1);
|
||||
expect("div.bg-info").toHaveCount(0);
|
||||
expect("button.btn-primary").toHaveCount(1);
|
||||
expect("button.btn-primary").toHaveText("Install");
|
||||
await contains(".fa-pencil").click();
|
||||
await contains("input").edit("<Otto&");
|
||||
expect.verifySteps(["URL replace"]);
|
||||
});
|
||||
|
||||
test("Installation page displays the error message when browser is not supported", async () => {
|
||||
delete browser.BeforeInstallPromptEvent;
|
||||
await makeMockEnv();
|
||||
mountManifestLink("/web/manifest.scoped_app_manifest");
|
||||
mockFetch((route) => {
|
||||
expect.step(route);
|
||||
return {
|
||||
icons: [
|
||||
{
|
||||
src: "/fake_image_src",
|
||||
sizes: "any",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
name: "My App",
|
||||
scope: "/scoped_app/myApp",
|
||||
start_url: "/scoped_app/myApp",
|
||||
};
|
||||
});
|
||||
|
||||
class Parent extends Component {
|
||||
static props = ["*"];
|
||||
static components = { InstallScopedApp };
|
||||
static template = xml`<InstallScopedApp/>`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect.verifySteps(["/web/manifest.scoped_app_manifest"]);
|
||||
await animationFrame();
|
||||
expect(".o_install_scoped_app").toHaveCount(1);
|
||||
expect(".o_install_scoped_app h1").toHaveText("My App");
|
||||
expect(".o_install_scoped_app img").toHaveAttribute("data-src", "/fake_image_src");
|
||||
expect("button.btn-primary").toHaveCount(0);
|
||||
expect("div.bg-info").toHaveCount(1);
|
||||
expect("div.bg-info").toHaveText("The app cannot be installed with this browser");
|
||||
});
|
||||
|
|
@ -0,0 +1,670 @@
|
|||
import { beforeEach, expect, test } from "@odoo/hoot";
|
||||
import { mockDate, mockTimeZone } from "@odoo/hoot-mock";
|
||||
import {
|
||||
defineParams,
|
||||
makeMockEnv,
|
||||
patchTranslations,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import {
|
||||
deserializeDate,
|
||||
deserializeDateTime,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
parseDate,
|
||||
parseDateTime,
|
||||
serializeDate,
|
||||
serializeDateTime,
|
||||
strftimeToLuxonFormat,
|
||||
} from "@web/core/l10n/dates";
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
|
||||
const { DateTime, Settings } = luxon;
|
||||
|
||||
const formats = {
|
||||
date: "%d.%m/%Y",
|
||||
time: "%H:%M:%S",
|
||||
};
|
||||
const dateFormat = strftimeToLuxonFormat(formats.date);
|
||||
const timeFormat = strftimeToLuxonFormat(formats.time);
|
||||
|
||||
beforeEach(() => {
|
||||
patchTranslations();
|
||||
});
|
||||
|
||||
test("formatDate/formatDateTime specs", async () => {
|
||||
patchWithCleanup(localization, {
|
||||
dateFormat: "MM/dd/yyyy",
|
||||
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
|
||||
});
|
||||
mockDate("2009-05-04 11:34:56", +1);
|
||||
|
||||
const utc = DateTime.utc(); // 2009-05-04T11:34:56.000Z
|
||||
const local = DateTime.local(); // 2009-05-04T12:34:56.000+01:00
|
||||
const minus13FromLocalTZ = local.setZone("UTC-12"); // 2009-05-03T23:34:56.000-12:00
|
||||
|
||||
// For dates, regardless of the input timezone, outputs only the date
|
||||
expect(formatDate(utc)).toBe("05/04/2009");
|
||||
expect(formatDate(local)).toBe("05/04/2009");
|
||||
expect(formatDate(minus13FromLocalTZ)).toBe("05/03/2009");
|
||||
|
||||
// For datetimes, input timezone is taken into account, outputs in local timezone
|
||||
expect(formatDateTime(utc)).toBe("05/04/2009 12:34:56");
|
||||
expect(formatDateTime(local)).toBe("05/04/2009 12:34:56");
|
||||
expect(formatDateTime(minus13FromLocalTZ)).toBe("05/04/2009 12:34:56");
|
||||
});
|
||||
|
||||
test("formatDate/formatDateTime specs, at midnight", async () => {
|
||||
patchWithCleanup(localization, {
|
||||
dateFormat: "MM/dd/yyyy",
|
||||
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
|
||||
});
|
||||
mockDate("2009-05-03 23:00:00", +1);
|
||||
|
||||
const utc = DateTime.utc(); // 2009-05-03T23:00:00.000Z
|
||||
const local = DateTime.local(); // 2009-05-04T00:00:00.000+01:00
|
||||
const minus13FromLocalTZ = local.setZone("UTC-12"); // 2009-05-03T11:00:00.000-12:00
|
||||
|
||||
// For dates, regardless of the input timezone, outputs only the date
|
||||
expect(formatDate(utc)).toBe("05/03/2009");
|
||||
expect(formatDate(local)).toBe("05/04/2009");
|
||||
expect(formatDate(minus13FromLocalTZ)).toBe("05/03/2009");
|
||||
|
||||
// For datetimes, input timezone is taken into account, outputs in local timezone
|
||||
expect(formatDateTime(utc)).toBe("05/04/2009 00:00:00");
|
||||
expect(formatDateTime(local)).toBe("05/04/2009 00:00:00");
|
||||
expect(formatDateTime(minus13FromLocalTZ)).toBe("05/04/2009 00:00:00");
|
||||
});
|
||||
|
||||
test("formatDate/formatDateTime with condensed option", async () => {
|
||||
mockDate("2009-05-03 08:00:00");
|
||||
mockTimeZone(0);
|
||||
const now = DateTime.now();
|
||||
|
||||
patchWithCleanup(localization, {
|
||||
dateFormat: "MM/dd/yyyy",
|
||||
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
|
||||
});
|
||||
expect(formatDate(now, { condensed: true })).toBe("5/3/2009");
|
||||
expect(formatDateTime(now, { condensed: true })).toBe("5/3/2009 8:00:00");
|
||||
|
||||
patchWithCleanup(localization, { dateFormat: "yyyy-MM-dd" });
|
||||
expect(formatDate(now, { condensed: true })).toBe("2009-5-3");
|
||||
|
||||
patchWithCleanup(localization, { dateFormat: "dd MMM yy" });
|
||||
expect(formatDate(now, { condensed: true })).toBe("3 May 09");
|
||||
});
|
||||
|
||||
test("formatDateTime in different timezone", async () => {
|
||||
patchWithCleanup(localization, {
|
||||
dateFormat: "MM/dd/yyyy",
|
||||
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
|
||||
});
|
||||
mockDate("2009-05-04 00:00:00", 0);
|
||||
expect(formatDateTime(DateTime.utc())).toBe("05/04/2009 00:00:00");
|
||||
expect(formatDateTime(DateTime.utc(), { tz: "Asia/Kolkata" })).toBe("05/04/2009 05:30:00");
|
||||
});
|
||||
|
||||
test("parseDate(Time) outputs DateTime objects in local TZ", async () => {
|
||||
await makeMockEnv();
|
||||
mockTimeZone(+1);
|
||||
expect(parseDate("01/13/2019").toISO()).toBe("2019-01-13T00:00:00.000+01:00");
|
||||
expect(parseDateTime("01/13/2019 10:05:45").toISO()).toBe("2019-01-13T10:05:45.000+01:00");
|
||||
|
||||
mockTimeZone(+5.5);
|
||||
expect(parseDate("01/13/2019").toISO()).toBe("2019-01-13T00:00:00.000+05:30");
|
||||
expect(parseDateTime("01/13/2019 10:05:45").toISO()).toBe("2019-01-13T10:05:45.000+05:30");
|
||||
|
||||
mockTimeZone(-11);
|
||||
expect(parseDate("01/13/2019").toISO()).toBe("2019-01-13T00:00:00.000-11:00");
|
||||
expect(parseDateTime("01/13/2019 10:05:45").toISO()).toBe("2019-01-13T10:05:45.000-11:00");
|
||||
});
|
||||
|
||||
test("parseDateTime in different timezone", async () => {
|
||||
await makeMockEnv();
|
||||
mockTimeZone(+1);
|
||||
expect(parseDateTime("01/13/2019 10:05:45").toISO()).toBe("2019-01-13T10:05:45.000+01:00");
|
||||
expect(parseDateTime("01/13/2019 10:05:45", { tz: "Asia/Kolkata" }).toISO()).toBe(
|
||||
"2019-01-13T10:05:45.000+05:30"
|
||||
);
|
||||
});
|
||||
|
||||
test("parseDate with different numbering system", async () => {
|
||||
patchWithCleanup(localization, {
|
||||
dateFormat: "dd MMM, yyyy",
|
||||
dateTimeFormat: "dd MMM, yyyy hh:mm:ss",
|
||||
timeFormat: "hh:mm:ss",
|
||||
});
|
||||
|
||||
patchWithCleanup(Settings, { defaultNumberingSystem: "arab", defaultLocale: "ar" });
|
||||
|
||||
expect(parseDate("٠١ فبراير, ٢٠٢٣").toISO()).toBe("2023-02-01T00:00:00.000+01:00");
|
||||
});
|
||||
|
||||
test("parseDateTime", async () => {
|
||||
expect(() => parseDateTime("13/01/2019 12:00:00")).toThrow(/is not a correct/, {
|
||||
message: "Wrongly formated dates should be invalid",
|
||||
});
|
||||
expect(() => parseDateTime("01/01/0999 12:00:00")).toThrow(/is not a correct/, {
|
||||
message: "Dates before 1000 should be invalid",
|
||||
});
|
||||
expect(() => parseDateTime("01/01/10000 12:00:00")).toThrow(/is not a correct/, {
|
||||
message: "Dates after 9999 should be invalid",
|
||||
});
|
||||
expect(() => parseDateTime("invalid value")).toThrow(/is not a correct/);
|
||||
|
||||
const expected = "2019-01-13T10:05:45.000+01:00";
|
||||
expect(parseDateTime("01/13/2019 10:05:45").toISO()).toBe(expected, {
|
||||
message: "Date with leading 0",
|
||||
});
|
||||
expect(parseDateTime("1/13/2019 10:5:45").toISO()).toBe(expected, {
|
||||
message: "Date without leading 0",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseDateTime (norwegian locale)", async () => {
|
||||
defineParams({
|
||||
lang: "no", // Norwegian
|
||||
lang_parameters: {
|
||||
date_format: "%d. %b %Y",
|
||||
time_format: "%H:%M:%S",
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
expect(parseDateTime("16. des 2019 10:05:45").toISO()).toBe("2019-12-16T10:05:45.000+01:00", {
|
||||
message: "Day/month inverted + month i18n",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseDate", async () => {
|
||||
await makeMockEnv();
|
||||
expect(parseDate("07/21/2022").toISO()).toBe("2022-07-21T00:00:00.000+01:00");
|
||||
expect(parseDate("07/22/2022").toISO()).toBe("2022-07-22T00:00:00.000+01:00");
|
||||
});
|
||||
|
||||
test("parseDate without separator", async () => {
|
||||
const dateFormat = strftimeToLuxonFormat("%d.%m/%Y");
|
||||
const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
|
||||
patchWithCleanup(localization, {
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
dateTimeFormat: `${dateFormat} ${timeFormat}`,
|
||||
});
|
||||
|
||||
const testDateFormat = "dd.MM/yyyy";
|
||||
|
||||
expect(() => parseDate("1137")).toThrow(/is not a correct/, {
|
||||
message: "Wrongly formated dates should be invalid",
|
||||
});
|
||||
expect(() => parseDate("1197")).toThrow(/is not a correct/, {
|
||||
message: "Wrongly formated dates should be invalid",
|
||||
});
|
||||
expect(() => parseDate("0131")).toThrow(/is not a correct/, {
|
||||
message: "Wrongly formated dates should be invalid",
|
||||
});
|
||||
expect(() => parseDate("970131")).toThrow(/is not a correct/, {
|
||||
message: "Wrongly formated dates should be invalid",
|
||||
});
|
||||
expect(parseDate("2001").toFormat(testDateFormat)).toBe("20.01/" + DateTime.utc().year);
|
||||
expect(parseDate("3101").toFormat(testDateFormat)).toBe("31.01/" + DateTime.utc().year);
|
||||
expect(parseDate("31.01").toFormat(testDateFormat)).toBe("31.01/" + DateTime.utc().year);
|
||||
expect(parseDate("310197").toFormat(testDateFormat)).toBe("31.01/1997");
|
||||
expect(parseDate("310117").toFormat(testDateFormat)).toBe("31.01/2017");
|
||||
expect(parseDate("31011985").toFormat(testDateFormat)).toBe("31.01/1985");
|
||||
});
|
||||
|
||||
test("parseDateTime without separator", async () => {
|
||||
const dateFormat = strftimeToLuxonFormat("%d.%m/%Y");
|
||||
const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
|
||||
patchWithCleanup(localization, {
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
dateTimeFormat: `${dateFormat} ${timeFormat}`,
|
||||
});
|
||||
|
||||
const dateTimeFormat = "dd.MM/yyyy HH:mm/ss";
|
||||
expect(parseDateTime("3101198508").toFormat(dateTimeFormat)).toBe("31.01/1985 08:00/00");
|
||||
expect(parseDateTime("310119850833").toFormat(dateTimeFormat)).toBe("31.01/1985 08:33/00");
|
||||
expect(parseDateTime("31/01/1985 08").toFormat(dateTimeFormat)).toBe("31.01/1985 08:00/00");
|
||||
});
|
||||
|
||||
test("parseDateTime with escaped characters (eg. Basque locale)", async () => {
|
||||
const dateFormat = strftimeToLuxonFormat("%a, %Y.eko %bren %da");
|
||||
const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
|
||||
patchWithCleanup(localization, {
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
dateTimeFormat: `${dateFormat} ${timeFormat}`,
|
||||
});
|
||||
|
||||
const dateTimeFormat = `${dateFormat} ${timeFormat}`;
|
||||
expect(dateTimeFormat).toBe("ccc, yyyy.'e''k''o' MMM'r''e''n' dd'a' HH:mm:ss");
|
||||
expect(parseDateTime("1985-01-31 08:30:00").toFormat(dateTimeFormat)).toBe(
|
||||
"Thu, 1985.eko Janren 31a 08:30:00"
|
||||
);
|
||||
});
|
||||
|
||||
test("parse smart date input", async () => {
|
||||
mockDate("2020-01-01 00:00:00", 0);
|
||||
|
||||
const format = "yyyy-MM-dd HH:mm";
|
||||
// with parseDate
|
||||
expect(parseDate("+0").toFormat(format)).toBe("2020-01-01 00:00");
|
||||
expect(parseDate("-0").toFormat(format)).toBe("2020-01-01 00:00");
|
||||
expect(parseDate("+1d").toFormat(format)).toBe("2020-01-02 00:00");
|
||||
expect(parseDate("+2w").toFormat(format)).toBe("2020-01-15 00:00");
|
||||
expect(parseDate("+3m").toFormat(format)).toBe("2020-04-01 00:00");
|
||||
expect(parseDate("+4y").toFormat(format)).toBe("2024-01-01 00:00");
|
||||
expect(parseDate("+5").toFormat(format)).toBe("2020-01-06 00:00");
|
||||
expect(parseDate("-5").toFormat(format)).toBe("2019-12-27 00:00");
|
||||
expect(parseDate("-4y").toFormat(format)).toBe("2016-01-01 00:00");
|
||||
expect(parseDate("-3m").toFormat(format)).toBe("2019-10-01 00:00");
|
||||
expect(parseDate("-2w").toFormat(format)).toBe("2019-12-18 00:00");
|
||||
expect(parseDate("-1d").toFormat(format)).toBe("2019-12-31 00:00");
|
||||
// with parseDateTime
|
||||
expect(parseDateTime("+0").toFormat(format)).toBe("2020-01-01 00:00");
|
||||
expect(parseDateTime("-0").toFormat(format)).toBe("2020-01-01 00:00");
|
||||
expect(parseDateTime("+1d").toFormat(format)).toBe("2020-01-02 00:00");
|
||||
expect(parseDateTime("+2w").toFormat(format)).toBe("2020-01-15 00:00");
|
||||
expect(parseDateTime("+3m").toFormat(format)).toBe("2020-04-01 00:00");
|
||||
expect(parseDateTime("+4y").toFormat(format)).toBe("2024-01-01 00:00");
|
||||
expect(parseDateTime("+5").toFormat(format)).toBe("2020-01-06 00:00");
|
||||
expect(parseDateTime("-5").toFormat(format)).toBe("2019-12-27 00:00");
|
||||
expect(parseDateTime("-4y").toFormat(format)).toBe("2016-01-01 00:00");
|
||||
expect(parseDateTime("-3m").toFormat(format)).toBe("2019-10-01 00:00");
|
||||
expect(parseDateTime("-2w").toFormat(format)).toBe("2019-12-18 00:00");
|
||||
expect(parseDateTime("-1d").toFormat(format)).toBe("2019-12-31 00:00");
|
||||
});
|
||||
|
||||
test("parseDateTime ISO8601 Format", async () => {
|
||||
mockTimeZone(+1);
|
||||
expect(parseDateTime("2017-05-15T12:00:00.000+06:00").toISO()).toBe(
|
||||
"2017-05-15T07:00:00.000+01:00"
|
||||
);
|
||||
// without the 'T' separator is not really ISO8601 compliant, but we still support it
|
||||
expect(parseDateTime("2017-05-15 12:00:00.000+06:00").toISO()).toBe(
|
||||
"2017-05-15T07:00:00.000+01:00"
|
||||
);
|
||||
});
|
||||
|
||||
test("parseDateTime SQL Format", async () => {
|
||||
expect(parseDateTime("2017-05-15 09:12:34").toISO()).toBe("2017-05-15T09:12:34.000+01:00");
|
||||
expect(parseDateTime("2017-05-08 09:12:34").toISO()).toBe("2017-05-08T09:12:34.000+01:00");
|
||||
});
|
||||
|
||||
test("serializeDate", async () => {
|
||||
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
|
||||
expect(date.toFormat("yyyy-MM-dd")).toBe("2022-02-21");
|
||||
expect(serializeDate(date)).toBe("2022-02-21");
|
||||
});
|
||||
|
||||
test("serializeDate, with DateTime.now()", async () => {
|
||||
mockDate("2022-02-21 15:11:42");
|
||||
const date = DateTime.now();
|
||||
expect(date.toFormat("yyyy-MM-dd")).toBe("2022-02-21");
|
||||
expect(serializeDate(date)).toBe("2022-02-21");
|
||||
});
|
||||
|
||||
test("serializeDate, with DateTime.now(), midnight", async () => {
|
||||
mockDate("2022-02-20 23:00:00");
|
||||
const date = DateTime.now();
|
||||
expect(date.toFormat("yyyy-MM-dd")).toBe("2022-02-21");
|
||||
expect(serializeDate(date)).toBe("2022-02-21");
|
||||
});
|
||||
|
||||
test("serializeDate with different numbering system", async () => {
|
||||
patchWithCleanup(Settings, { defaultNumberingSystem: "arab" });
|
||||
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
|
||||
expect(date.toFormat("yyyy-MM-dd")).toBe("٢٠٢٢-٠٢-٢١");
|
||||
expect(serializeDate(date)).toBe("2022-02-21");
|
||||
});
|
||||
|
||||
test("serializeDateTime", async () => {
|
||||
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
|
||||
expect(date.toFormat("yyyy-MM-dd HH:mm:ss")).toBe("2022-02-21 16:11:42");
|
||||
expect(serializeDateTime(date)).toBe("2022-02-21 16:11:42");
|
||||
});
|
||||
|
||||
test("serializeDateTime, with DateTime.now()", async () => {
|
||||
mockDate("2022-02-21 15:11:42");
|
||||
const date = DateTime.now();
|
||||
expect(date.toFormat("yyyy-MM-dd HH:mm:ss")).toBe("2022-02-21 16:11:42");
|
||||
expect(serializeDateTime(date)).toBe("2022-02-21 15:11:42");
|
||||
});
|
||||
|
||||
test("serializeDateTime, with DateTime.now(), midnight", async () => {
|
||||
mockDate("2022-02-20 23:00:00");
|
||||
const date = DateTime.now();
|
||||
expect(date.toFormat("yyyy-MM-dd HH:mm:ss")).toBe("2022-02-21 00:00:00");
|
||||
expect(serializeDateTime(date)).toBe("2022-02-20 23:00:00");
|
||||
});
|
||||
|
||||
test("serializeDateTime with different numbering system", async () => {
|
||||
patchWithCleanup(Settings, { defaultNumberingSystem: "arab" });
|
||||
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
|
||||
expect(date.toFormat("yyyy-MM-dd HH:mm:ss")).toBe("٢٠٢٢-٠٢-٢١ ١٦:١١:٤٢");
|
||||
expect(serializeDateTime(date)).toBe("2022-02-21 16:11:42");
|
||||
});
|
||||
|
||||
test("deserializeDate", async () => {
|
||||
const date = DateTime.local(2022, 2, 21);
|
||||
expect(DateTime.fromFormat("2022-02-21", "yyyy-MM-dd").toMillis()).toBe(date.toMillis());
|
||||
expect(deserializeDate("2022-02-21").toMillis()).toBe(date.toMillis());
|
||||
});
|
||||
|
||||
test("deserializeDate with different numbering system", async () => {
|
||||
patchWithCleanup(Settings, { defaultNumberingSystem: "arab" });
|
||||
const date = DateTime.local(2022, 2, 21);
|
||||
expect(DateTime.fromFormat("٢٠٢٢-٠٢-٢١", "yyyy-MM-dd").toMillis()).toBe(date.toMillis());
|
||||
expect(deserializeDate("2022-02-21").toMillis()).toBe(date.toMillis());
|
||||
});
|
||||
|
||||
test("deserializeDateTime", async () => {
|
||||
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
|
||||
expect(
|
||||
DateTime.fromFormat("2022-02-21 16:11:42", "yyyy-MM-dd HH:mm:ss", {
|
||||
zone: "utc",
|
||||
}).toMillis()
|
||||
).toBe(date.toMillis());
|
||||
expect(deserializeDateTime("2022-02-21 16:11:42").toMillis()).toBe(date.toMillis());
|
||||
});
|
||||
|
||||
test("deserializeDateTime with different numbering system", async () => {
|
||||
patchWithCleanup(Settings, { defaultNumberingSystem: "arab" });
|
||||
const date = DateTime.utc(2022, 2, 21, 16, 11, 42);
|
||||
expect(
|
||||
DateTime.fromFormat("٢٠٢٢-٠٢-٢١ ١٦:١١:٤٢", "yyyy-MM-dd HH:mm:ss", {
|
||||
zone: "utc",
|
||||
}).toMillis()
|
||||
).toBe(date.toMillis());
|
||||
expect(deserializeDateTime("2022-02-21 16:11:42").toMillis()).toBe(date.toMillis());
|
||||
});
|
||||
|
||||
test("deserializeDateTime with different timezone", async () => {
|
||||
const date = DateTime.utc(2022, 2, 21, 16, 11, 42).setZone("Europe/Brussels");
|
||||
expect(deserializeDateTime("2022-02-21 16:11:42", { tz: "Europe/Brussels" }).c).toEqual(date.c);
|
||||
});
|
||||
|
||||
test("parseDate with short notations", async () => {
|
||||
expect(parseDate("20-10-20", { format: "yyyy-MM-dd" }).toISO()).toBe(
|
||||
"2020-10-20T00:00:00.000+01:00"
|
||||
);
|
||||
expect(parseDate("20/10/20", { format: "yyyy/MM/dd" }).toISO()).toBe(
|
||||
"2020-10-20T00:00:00.000+01:00"
|
||||
);
|
||||
expect(parseDate("10-20-20", { format: "MM-dd-yyyy" }).toISO()).toBe(
|
||||
"2020-10-20T00:00:00.000+01:00"
|
||||
);
|
||||
expect(parseDate("10-20-20", { format: "MM-yyyy-dd" }).toISO()).toBe(
|
||||
"2020-10-20T00:00:00.000+01:00"
|
||||
);
|
||||
expect(parseDate("1-20-2", { format: "MM-yyyy-dd" }).toISO()).toBe(
|
||||
"2020-01-02T00:00:00.000+01:00"
|
||||
);
|
||||
expect(parseDate("20/1/2", { format: "yyyy/MM/dd" }).toISO()).toBe(
|
||||
"2020-01-02T00:00:00.000+01:00"
|
||||
);
|
||||
});
|
||||
|
||||
test("parseDateTime with short notations", async () => {
|
||||
expect(parseDateTime("20-10-20 8:5:3", { format: "yyyy-MM-dd hh:mm:ss" }).toISO()).toBe(
|
||||
"2020-10-20T08:05:03.000+01:00"
|
||||
);
|
||||
});
|
||||
|
||||
test("parseDate with textual month notation", async () => {
|
||||
patchWithCleanup(localization, {
|
||||
dateFormat: "MMM/dd/yyyy",
|
||||
});
|
||||
expect(parseDate("Jan/05/1997").toISO()).toBe("1997-01-05T00:00:00.000+01:00");
|
||||
expect(parseDate("Jan/05/1997", { format: undefined }).toISO()).toBe(
|
||||
"1997-01-05T00:00:00.000+01:00"
|
||||
);
|
||||
expect(parseDate("Jan/05/1997", { format: "MMM/dd/yyyy" }).toISO()).toBe(
|
||||
"1997-01-05T00:00:00.000+01:00"
|
||||
);
|
||||
});
|
||||
|
||||
test("parseDate (various entries)", async () => {
|
||||
mockDate("2020-07-15 12:30:00", 0);
|
||||
patchWithCleanup(localization, {
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
dateTimeFormat: `${dateFormat} ${timeFormat}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Type of testSet key: string
|
||||
* Type of testSet value: string | undefined
|
||||
*/
|
||||
const testSet = new Map([
|
||||
["10101010101010", undefined],
|
||||
["1191111", "1191-04-21T00:00:00.000Z"], // day 111 of year 1191
|
||||
["11911111", "1191-11-11T00:00:00.000Z"],
|
||||
["3101", "2020-01-31T00:00:00.000Z"],
|
||||
["310160", "2060-01-31T00:00:00.000Z"],
|
||||
["311260", "2060-12-31T00:00:00.000Z"],
|
||||
|
||||
["310161", "1961-01-31T00:00:00.000Z"],
|
||||
["310165", "1965-01-31T00:00:00.000Z"],
|
||||
["310168", "1968-01-31T00:00:00.000Z"],
|
||||
["311268", "1968-12-31T00:00:00.000Z"],
|
||||
|
||||
["310169", "1969-01-31T00:00:00.000Z"],
|
||||
["310170", "1970-01-31T00:00:00.000Z"],
|
||||
["310197", "1997-01-31T00:00:00.000Z"],
|
||||
["310117", "2017-01-31T00:00:00.000Z"],
|
||||
["31011985", "1985-01-31T00:00:00.000Z"],
|
||||
["3101198508", undefined],
|
||||
["310119850833", undefined],
|
||||
|
||||
["1137", undefined],
|
||||
["1197", undefined],
|
||||
["0131", undefined],
|
||||
["0922", undefined],
|
||||
["2020", undefined],
|
||||
|
||||
["199901", "1999-01-01T00:00:00.000Z"],
|
||||
["30100210", "3010-02-10T00:00:00.000Z"],
|
||||
["3010210", "3010-07-29T00:00:00.000Z"],
|
||||
|
||||
["970131", undefined],
|
||||
["31.01", "2020-01-31T00:00:00.000Z"],
|
||||
["31/01/1985 08", undefined],
|
||||
|
||||
["01121934", "1934-12-01T00:00:00.000Z"],
|
||||
["011234", "2034-12-01T00:00:00.000Z"],
|
||||
["011260", "2060-12-01T00:00:00.000Z"],
|
||||
["2", "2020-07-02T00:00:00.000Z"],
|
||||
["02", "2020-07-02T00:00:00.000Z"],
|
||||
["20", "2020-07-20T00:00:00.000Z"],
|
||||
["202", "2020-02-20T00:00:00.000Z"],
|
||||
["2002", "2020-02-20T00:00:00.000Z"],
|
||||
["0202", "2020-02-02T00:00:00.000Z"],
|
||||
["02/02", "2020-02-02T00:00:00.000Z"],
|
||||
["02/13", undefined],
|
||||
["02/1313", undefined],
|
||||
["09990101", undefined],
|
||||
["19990101", "1999-01-01T00:00:00.000Z"],
|
||||
["19990130", "1999-01-30T00:00:00.000Z"],
|
||||
["19991230", "1999-12-30T00:00:00.000Z"],
|
||||
["19993012", undefined],
|
||||
["2016-200", "2016-07-18T00:00:00.000Z"],
|
||||
["2016200", "2016-07-18T00:00:00.000Z"], // day 200 of year 2016
|
||||
["2020-", undefined],
|
||||
["2020-W2", undefined],
|
||||
["2020W23", "2020-06-01T00:00:00.000Z"],
|
||||
["2020-W02", "2020-01-06T00:00:00.000Z"],
|
||||
["2020-W32", "2020-08-03T00:00:00.000Z"],
|
||||
["2020-W32-3", "2020-08-05T00:00:00.000Z"],
|
||||
["2016-W21-3", "2016-05-25T00:00:00.000Z"],
|
||||
["2016W213", "2016-05-25T00:00:00.000Z"],
|
||||
["2209", "2020-09-22T00:00:00.000Z"],
|
||||
["22:09", "2020-09-22T00:00:00.000Z"],
|
||||
["2012", "2020-12-20T00:00:00.000Z"],
|
||||
|
||||
["2016-01-03 09:24:15.123", "2016-01-03T00:00:00.000Z"],
|
||||
["2016-01-03T09:24:15.123", "2016-01-03T00:00:00.000Z"],
|
||||
["2016-01-03T09:24:15.123+06:00", "2016-01-03T00:00:00.000Z"],
|
||||
["2016-01-03T09:24:15.123+16:00", "2016-01-02T00:00:00.000Z"],
|
||||
["2016-01-03T09:24:15.123Z", "2016-01-03T00:00:00.000Z"],
|
||||
["2016-W21-3T09:24:15.123", "2016-05-25T00:00:00.000Z"],
|
||||
["2016-W21-3 09:24:15.123", undefined],
|
||||
|
||||
["2016-03-27T02:00:00.000+02:00", "2016-03-27T00:00:00.000Z"],
|
||||
["2016-03-27T03:00:00.000+02:00", "2016-03-27T00:00:00.000Z"],
|
||||
["2016-03-27T02:00:00.000", "2016-03-27T00:00:00.000Z"],
|
||||
["2016-03-27T03:00:00.000", "2016-03-27T00:00:00.000Z"],
|
||||
["2016-03-27T02:00:00.000Z", "2016-03-27T00:00:00.000Z"],
|
||||
["2016-03-27T03:00:00.000Z", "2016-03-27T00:00:00.000Z"],
|
||||
|
||||
["09:22", undefined],
|
||||
["2013", undefined],
|
||||
["011261", "1961-12-01T00:00:00.000Z"],
|
||||
|
||||
["932-10-10", undefined], // year < 1000 are not supported
|
||||
["1932-10-10", "1932-10-10T00:00:00.000Z"],
|
||||
["2016-01-03 09:24:15.123+06:00", "2016-01-03T00:00:00.000Z"],
|
||||
["2016-01-03 09:24:15.123+16:00", "2016-01-02T00:00:00.000Z"],
|
||||
["2016-01-03 09:24:15.123Z", "2016-01-03T00:00:00.000Z"],
|
||||
]);
|
||||
|
||||
for (const [input, expected] of testSet.entries()) {
|
||||
if (!expected) {
|
||||
expect(() => parseDate(input).toISO()).toThrow(/is not a correct/);
|
||||
} else {
|
||||
expect(parseDate(input).toISO()).toBe(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("parseDateTime (various entries)", async () => {
|
||||
mockDate("2020-07-15 11:30:00", 0);
|
||||
patchWithCleanup(localization, {
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
dateTimeFormat: `${dateFormat} ${timeFormat}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Type of testSet key: string
|
||||
* Type of testSet value: string | undefined
|
||||
*/
|
||||
const testSet = new Map([
|
||||
["10101010101010", "1010-10-10T10:10:10.000Z"],
|
||||
["1191111", "1191-04-21T00:00:00.000Z"], // day 111 of year 1191
|
||||
["11911111", "1191-11-11T00:00:00.000Z"],
|
||||
["3101", "2020-01-31T00:00:00.000Z"],
|
||||
["310160", "2060-01-31T00:00:00.000Z"],
|
||||
["311260", "2060-12-31T00:00:00.000Z"],
|
||||
["310161", "1961-01-31T00:00:00.000Z"],
|
||||
["310165", "1965-01-31T00:00:00.000Z"],
|
||||
["310168", "1968-01-31T00:00:00.000Z"],
|
||||
["311268", "1968-12-31T00:00:00.000Z"],
|
||||
["310169", "1969-01-31T00:00:00.000Z"],
|
||||
["310170", "1970-01-31T00:00:00.000Z"],
|
||||
["310197", "1997-01-31T00:00:00.000Z"],
|
||||
["310117", "2017-01-31T00:00:00.000Z"],
|
||||
["31011985", "1985-01-31T00:00:00.000Z"],
|
||||
["3101198508", "1985-01-31T08:00:00.000Z"],
|
||||
["310119850833", "1985-01-31T08:33:00.000Z"],
|
||||
["1137", undefined],
|
||||
["1197", undefined],
|
||||
["0131", undefined],
|
||||
["0922", undefined],
|
||||
["2020", undefined],
|
||||
["199901", "1999-01-01T00:00:00.000Z"],
|
||||
["30100210", "3010-02-10T00:00:00.000Z"],
|
||||
["3010210", "3010-07-29T00:00:00.000Z"],
|
||||
["970131", undefined],
|
||||
["31.01", "2020-01-31T00:00:00.000Z"],
|
||||
["31/01/1985 08", "1985-01-31T08:00:00.000Z"],
|
||||
|
||||
["01121934", "1934-12-01T00:00:00.000Z"],
|
||||
["011234", "2034-12-01T00:00:00.000Z"],
|
||||
["011260", "2060-12-01T00:00:00.000Z"],
|
||||
["2", "2020-07-02T00:00:00.000Z"],
|
||||
["02", "2020-07-02T00:00:00.000Z"],
|
||||
["20", "2020-07-20T00:00:00.000Z"],
|
||||
["202", "2020-02-20T00:00:00.000Z"],
|
||||
["2002", "2020-02-20T00:00:00.000Z"],
|
||||
["0202", "2020-02-02T00:00:00.000Z"],
|
||||
["02/02", "2020-02-02T00:00:00.000Z"],
|
||||
["02/13", undefined],
|
||||
["02/1313", undefined],
|
||||
["09990101", undefined],
|
||||
["19990101", "1999-01-01T00:00:00.000Z"],
|
||||
["19990130", "1999-01-30T00:00:00.000Z"],
|
||||
["19991230", "1999-12-30T00:00:00.000Z"],
|
||||
["19993012", undefined],
|
||||
["2016-200", "2016-07-18T00:00:00.000Z"],
|
||||
["2016200", "2016-07-18T00:00:00.000Z"], // day 200 of year 2016
|
||||
["2020-", undefined],
|
||||
["2020-W2", undefined],
|
||||
["2020W23", "2020-06-01T00:00:00.000Z"],
|
||||
["2020-W02", "2020-01-06T00:00:00.000Z"],
|
||||
["2020-W32", "2020-08-03T00:00:00.000Z"],
|
||||
["2020-W32-3", "2020-08-05T00:00:00.000Z"],
|
||||
["2016-W21-3", "2016-05-25T00:00:00.000Z"],
|
||||
["2016W213", "2016-05-25T00:00:00.000Z"],
|
||||
["2209", "2020-09-22T00:00:00.000Z"],
|
||||
["22:09", "2020-09-22T00:00:00.000Z"],
|
||||
["2012", "2020-12-20T00:00:00.000Z"],
|
||||
|
||||
["2016-01-03 09:24:15.123", "2016-01-03T09:24:15.123Z"],
|
||||
["2016-01-03T09:24:15.123", "2016-01-03T09:24:15.123Z"],
|
||||
["2016-01-03T09:24:15.123+06:00", "2016-01-03T03:24:15.123Z"],
|
||||
["2016-01-03T09:24:15.123+16:00", "2016-01-02T17:24:15.123Z"],
|
||||
["2016-01-03T09:24:15.123Z", "2016-01-03T09:24:15.123Z"],
|
||||
["2016-W21-3T09:24:15.123", "2016-05-25T09:24:15.123Z"],
|
||||
["2016-W21-3 09:24:15.123", undefined],
|
||||
|
||||
["2016-03-27T02:00:00.000+02:00", "2016-03-27T00:00:00.000Z"],
|
||||
["2016-03-27T03:00:00.000+02:00", "2016-03-27T01:00:00.000Z"],
|
||||
["2016-03-27T02:00:00.000", "2016-03-27T02:00:00.000Z"],
|
||||
["2016-03-27T03:00:00.000", "2016-03-27T03:00:00.000Z"],
|
||||
["2016-03-27T02:00:00.000Z", "2016-03-27T02:00:00.000Z"],
|
||||
["2016-03-27T03:00:00.000Z", "2016-03-27T03:00:00.000Z"],
|
||||
|
||||
["09:22", undefined],
|
||||
["2013", undefined],
|
||||
["011261", "1961-12-01T00:00:00.000Z"],
|
||||
|
||||
["932-10-10", undefined],
|
||||
["1932-10-10", "1932-10-10T00:00:00.000Z"],
|
||||
["2016-01-03 09:24:15.123+06:00", "2016-01-03T03:24:15.123Z"],
|
||||
["2016-01-03 09:24:15.123+16:00", "2016-01-02T17:24:15.123Z"],
|
||||
["2016-01-03 09:24:15.123Z", "2016-01-03T09:24:15.123Z"],
|
||||
]);
|
||||
|
||||
for (const [input, expected] of testSet.entries()) {
|
||||
if (!expected) {
|
||||
expect(() => parseDateTime(input).toISO()).toThrow(/is not a correct/);
|
||||
} else {
|
||||
expect(parseDateTime(input).toISO()).toBe(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("parseDateTime: arab locale, latin numbering system as input", async () => {
|
||||
defineParams({
|
||||
lang: "ar_001",
|
||||
lang_parameters: {
|
||||
date_format: "%d %b, %Y",
|
||||
time_format: "%H:%M:%S",
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
// Check it works with arab
|
||||
expect(parseDateTime("١٥ يوليو, ٢٠٢٠ ١٢:٣٠:٤٣").toISO().split(".")[0]).toBe(
|
||||
"2020-07-15T12:30:43"
|
||||
);
|
||||
|
||||
// Check it also works with latin numbers
|
||||
expect(parseDateTime("15 07, 2020 12:30:43").toISO().split(".")[0]).toBe("2020-07-15T12:30:43");
|
||||
expect(parseDateTime("22/01/2023").toISO().split(".")[0]).toBe("2023-01-22T00:00:00");
|
||||
expect(parseDateTime("2023-01-22").toISO().split(".")[0]).toBe("2023-01-22T00:00:00");
|
||||
});
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import { after, describe, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
defineParams,
|
||||
makeMockEnv,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchTranslations,
|
||||
patchWithCleanup,
|
||||
serverState,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { _t, translatedTerms, translationLoaded } from "@web/core/l10n/translation";
|
||||
import { session } from "@web/session";
|
||||
|
||||
import { Component, markup, xml } from "@odoo/owl";
|
||||
const { DateTime } = luxon;
|
||||
|
||||
const frenchTerms = { Hello: "Bonjour" };
|
||||
class TestComponent extends Component {
|
||||
static template = "";
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the 'lang' of the user session and context.
|
||||
*
|
||||
* @param {string} lang
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function mockLang(lang) {
|
||||
serverState.lang = lang;
|
||||
await makeMockEnv();
|
||||
}
|
||||
|
||||
test("lang is given by the user context", async () => {
|
||||
onRpc("/web/webclient/translations/*", (request) => {
|
||||
const urlParams = new URLSearchParams(new URL(request.url).search);
|
||||
expect.step(urlParams.get("lang"));
|
||||
});
|
||||
await mockLang("fr_FR");
|
||||
expect.verifySteps(["fr_FR"]);
|
||||
});
|
||||
|
||||
test("lang is given by an attribute on the DOM root node", async () => {
|
||||
serverState.lang = null;
|
||||
onRpc("/web/webclient/translations/*", (request) => {
|
||||
const urlParams = new URLSearchParams(new URL(request.url).search);
|
||||
expect.step(urlParams.get("lang"));
|
||||
});
|
||||
document.documentElement.setAttribute("lang", "fr-FR");
|
||||
after(() => {
|
||||
document.documentElement.removeAttribute("lang");
|
||||
});
|
||||
await makeMockEnv();
|
||||
expect.verifySteps(["fr_FR"]);
|
||||
});
|
||||
|
||||
test("url is given by the session", async () => {
|
||||
expect.assertions(1);
|
||||
patchWithCleanup(session, {
|
||||
translationURL: "/get_translations",
|
||||
});
|
||||
onRpc(
|
||||
"/get_translations/*",
|
||||
function (request) {
|
||||
expect(request.url).toInclude("/get_translations/");
|
||||
return this.loadTranslations();
|
||||
},
|
||||
{ pure: true }
|
||||
);
|
||||
await makeMockEnv();
|
||||
});
|
||||
|
||||
test("can translate a text node", async () => {
|
||||
TestComponent.template = xml`<div id="main">Hello</div>`;
|
||||
defineParams({
|
||||
translations: frenchTerms,
|
||||
});
|
||||
await mountWithCleanup(TestComponent);
|
||||
expect("#main").toHaveText("Bonjour");
|
||||
});
|
||||
|
||||
test("can lazy translate", async () => {
|
||||
// Can't use patchWithCleanup cause it doesn't support Symbol
|
||||
translatedTerms[translationLoaded] = false;
|
||||
TestComponent.template = xml`<div id="main"><t t-esc="constructor.someLazyText" /></div>`;
|
||||
TestComponent.someLazyText = _t("Hello");
|
||||
expect(() => TestComponent.someLazyText.toString()).toThrow();
|
||||
expect(() => TestComponent.someLazyText.valueOf()).toThrow();
|
||||
defineParams({
|
||||
translations: frenchTerms,
|
||||
});
|
||||
await mountWithCleanup(TestComponent);
|
||||
expect("#main").toHaveText("Bonjour");
|
||||
});
|
||||
|
||||
test("luxon is configured in the correct lang", async () => {
|
||||
await mockLang("fr_BE");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("MMMM")).toBe("décembre");
|
||||
});
|
||||
|
||||
test("arabic has the correct numbering system (generic)", async () => {
|
||||
await mockLang("ar_001");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("١٠/١٢/٢٠٢١ ١٢:٠٠:٠٠");
|
||||
});
|
||||
|
||||
test("arabic has the correct numbering system (Algeria)", async () => {
|
||||
await mockLang("ar_DZ");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
|
||||
});
|
||||
|
||||
test("arabic has the correct numbering system (Lybia)", async () => {
|
||||
await mockLang("ar_LY");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
|
||||
});
|
||||
|
||||
test("arabic has the correct numbering system (Morocco)", async () => {
|
||||
await mockLang("ar_MA");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
|
||||
});
|
||||
|
||||
test("arabic has the correct numbering system (Saudi Arabia)", async () => {
|
||||
await mockLang("ar_SA");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("١٠/١٢/٢٠٢١ ١٢:٠٠:٠٠");
|
||||
});
|
||||
|
||||
test("arabic has the correct numbering system (Tunisia)", async () => {
|
||||
await mockLang("ar_TN");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
|
||||
});
|
||||
|
||||
test("bengalese has the correct numbering system", async () => {
|
||||
await mockLang("bn");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("১০/১২/২০২১ ১২:০০:০০");
|
||||
});
|
||||
|
||||
test("punjabi (gurmukhi) has the correct numbering system", async () => {
|
||||
await mockLang("pa_IN");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("੧੦/੧੨/੨੦੨੧ ੧੨:੦੦:੦੦");
|
||||
});
|
||||
|
||||
test("tamil has the correct numbering system", async () => {
|
||||
await mockLang("ta");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("௧௦/௧௨/௨௦௨௧ ௧௨:௦௦:௦௦");
|
||||
});
|
||||
|
||||
test("_t fills the format specifiers in translated terms with its extra arguments", async () => {
|
||||
patchTranslations({
|
||||
"Due in %s days": "Échéance dans %s jours",
|
||||
});
|
||||
const translatedStr = _t("Due in %s days", 513);
|
||||
expect(translatedStr).toBe("Échéance dans 513 jours");
|
||||
});
|
||||
|
||||
test("_t fills the format specifiers in lazy translated terms with its extra arguments", async () => {
|
||||
translatedTerms[translationLoaded] = false;
|
||||
const translatedStr = _t("Due in %s days", 513);
|
||||
patchTranslations({
|
||||
"Due in %s days": "Échéance dans %s jours",
|
||||
});
|
||||
expect(translatedStr.toString()).toBe("Échéance dans 513 jours");
|
||||
});
|
||||
|
||||
describe("_t with markups", () => {
|
||||
test("non-markup values are escaped", () => {
|
||||
translatedTerms[translationLoaded] = true;
|
||||
const maliciousUserInput = "<script>alert('This should've been escaped')</script>";
|
||||
const translatedStr = _t(
|
||||
"FREE %(blink_start)sROBUX%(blink_end)s, please contact %(email)s",
|
||||
{
|
||||
blink_start: markup("<blink>"),
|
||||
blink_end: markup("</blink>"),
|
||||
email: maliciousUserInput,
|
||||
}
|
||||
);
|
||||
expect(translatedStr).toBeInstanceOf(markup().constructor);
|
||||
expect(translatedStr.valueOf()).toBe(
|
||||
"FREE <blink>ROBUX</blink>, please contact <script>alert('This should've been escaped')</script>"
|
||||
);
|
||||
});
|
||||
test("translations are escaped", () => {
|
||||
translatedTerms[translationLoaded] = true;
|
||||
const maliciousTranslation = "<script>document.write('pizza hawai')</script> %s";
|
||||
patchTranslations({ "I love %s": maliciousTranslation });
|
||||
const translatedStr = _t("I love %s", markup("<blink>Mario Kart</blink>"));
|
||||
expect(translatedStr.valueOf()).toBe(
|
||||
"<script>document.write('pizza hawai')</script> <blink>Mario Kart</blink>"
|
||||
);
|
||||
});
|
||||
});
|
||||
258
odoo-bringout-oca-ocb-web/web/static/tests/core/macro.test.js
Normal file
258
odoo-bringout-oca-ocb-web/web/static/tests/core/macro.test.js
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { beforeEach, expect, test } from "@odoo/hoot";
|
||||
import { advanceTime, animationFrame, click, edit, queryOne } from "@odoo/hoot-dom";
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { Macro } from "@web/core/macro";
|
||||
|
||||
let macro;
|
||||
async function waitForMacro() {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await animationFrame();
|
||||
await advanceTime(265);
|
||||
if (macro.isComplete) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!macro.isComplete) {
|
||||
throw new Error(`Macro is not complete`);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
patchWithCleanup(Macro.prototype, {
|
||||
start() {
|
||||
super.start(...arguments);
|
||||
macro = this;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function onTextChange(element, callback) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === "characterData" || mutation.type === "childList") {
|
||||
callback(element.textContent);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(element, {
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
return observer;
|
||||
}
|
||||
|
||||
class TestComponent extends Component {
|
||||
static template = xml`
|
||||
<div class="counter">
|
||||
<p><button class="btn inc" t-on-click="() => this.state.value++">increment</button></p>
|
||||
<p><button class="btn dec" t-on-click="() => this.state.value--">decrement</button></p>
|
||||
<p><button class="btn double" t-on-click="() => this.state.value = 2*this.state.value">double</button></p>
|
||||
<span class="value"><t t-esc="state.value"/></span>
|
||||
<input />
|
||||
</div>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ value: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
test("simple use", async () => {
|
||||
await mountWithCleanup(TestComponent);
|
||||
new Macro({
|
||||
name: "test",
|
||||
steps: [
|
||||
{
|
||||
trigger: "button.inc",
|
||||
async action(trigger) {
|
||||
await click(trigger);
|
||||
},
|
||||
},
|
||||
],
|
||||
}).start(queryOne(".counter"));
|
||||
|
||||
const span = queryOne("span.value");
|
||||
expect(span).toHaveText("0");
|
||||
onTextChange(span, expect.step);
|
||||
await waitForMacro();
|
||||
expect.verifySteps(["1"]);
|
||||
});
|
||||
|
||||
test("multiple steps", async () => {
|
||||
await mountWithCleanup(TestComponent);
|
||||
const span = queryOne("span.value");
|
||||
expect(span).toHaveText("0");
|
||||
|
||||
new Macro({
|
||||
name: "test",
|
||||
steps: [
|
||||
{
|
||||
trigger: "button.inc",
|
||||
async action(trigger) {
|
||||
await click(trigger);
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: () => (span.textContent === "1" ? span : null),
|
||||
},
|
||||
{
|
||||
trigger: "button.inc",
|
||||
async action(trigger) {
|
||||
await click(trigger);
|
||||
},
|
||||
},
|
||||
],
|
||||
}).start(queryOne(".counter"));
|
||||
onTextChange(span, expect.step);
|
||||
await waitForMacro();
|
||||
expect.verifySteps(["1", "2"]);
|
||||
});
|
||||
|
||||
test("can input values", async () => {
|
||||
await mountWithCleanup(TestComponent);
|
||||
const input = queryOne("input");
|
||||
new Macro({
|
||||
name: "test",
|
||||
steps: [
|
||||
{
|
||||
trigger: "div.counter input",
|
||||
async action(trigger) {
|
||||
await click(trigger);
|
||||
await edit("aaron", { confirm: "blur" });
|
||||
},
|
||||
},
|
||||
],
|
||||
}).start(queryOne(".counter"));
|
||||
expect(input).toHaveValue("");
|
||||
await waitForMacro();
|
||||
expect(input).toHaveValue("aaron");
|
||||
});
|
||||
|
||||
test("a step can have no trigger", async () => {
|
||||
await mountWithCleanup(TestComponent);
|
||||
const input = queryOne("input");
|
||||
new Macro({
|
||||
name: "test",
|
||||
steps: [
|
||||
{ action: () => expect.step("1") },
|
||||
{ action: () => expect.step("2") },
|
||||
{
|
||||
trigger: "div.counter input",
|
||||
async action(trigger) {
|
||||
await click(trigger);
|
||||
await edit("aaron", { confirm: "blur" });
|
||||
},
|
||||
},
|
||||
{ action: () => expect.step("3") },
|
||||
],
|
||||
}).start(queryOne(".counter"));
|
||||
expect(input).toHaveValue("");
|
||||
await waitForMacro();
|
||||
expect(input).toHaveValue("aaron");
|
||||
expect.verifySteps(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
test("onStep function is called at each step", async () => {
|
||||
await mountWithCleanup(TestComponent);
|
||||
const span = queryOne("span.value");
|
||||
expect(span).toHaveText("0");
|
||||
|
||||
new Macro({
|
||||
name: "test",
|
||||
onStep: (el, step, index) => {
|
||||
expect.step(index);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
action: () => {
|
||||
console.log("brol");
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: "button.inc",
|
||||
async action(trigger) {
|
||||
await click(trigger);
|
||||
},
|
||||
},
|
||||
],
|
||||
}).start(queryOne(".counter"));
|
||||
await waitForMacro();
|
||||
expect(span).toHaveText("1");
|
||||
expect.verifySteps([0, 1]);
|
||||
});
|
||||
|
||||
test("trigger can be a function returning an htmlelement", async () => {
|
||||
await mountWithCleanup(TestComponent);
|
||||
const span = queryOne("span.value");
|
||||
expect(span).toHaveText("0");
|
||||
|
||||
new Macro({
|
||||
name: "test",
|
||||
steps: [
|
||||
{
|
||||
trigger: () => queryOne("button.inc"),
|
||||
async action(trigger) {
|
||||
await click(trigger);
|
||||
},
|
||||
},
|
||||
],
|
||||
}).start(queryOne(".counter"));
|
||||
expect(span).toHaveText("0");
|
||||
await waitForMacro();
|
||||
expect(span).toHaveText("1");
|
||||
});
|
||||
|
||||
test("macro wait element is visible to do action", async () => {
|
||||
await mountWithCleanup(TestComponent);
|
||||
const span = queryOne("span.value");
|
||||
const button = queryOne("button.inc");
|
||||
button.classList.add("d-none");
|
||||
expect(span).toHaveText("0");
|
||||
new Macro({
|
||||
name: "test",
|
||||
timeout: 1000,
|
||||
steps: [
|
||||
{
|
||||
trigger: "button.inc",
|
||||
action: () => {
|
||||
expect.step("element is now visible");
|
||||
},
|
||||
},
|
||||
],
|
||||
onError: (error) => {
|
||||
expect.step(error);
|
||||
},
|
||||
}).start(queryOne(".counter"));
|
||||
advanceTime(500);
|
||||
button.classList.remove("d-none");
|
||||
await waitForMacro();
|
||||
expect.verifySteps(["element is now visible"]);
|
||||
});
|
||||
|
||||
test("macro timeout if element is not visible", async () => {
|
||||
await mountWithCleanup(TestComponent);
|
||||
const span = queryOne("span.value");
|
||||
const button = queryOne("button.inc");
|
||||
button.classList.add("d-none");
|
||||
expect(span).toHaveText("0");
|
||||
const macro = new Macro({
|
||||
name: "test",
|
||||
timeout: 1000,
|
||||
steps: [
|
||||
{
|
||||
trigger: "button.inc",
|
||||
action: () => {
|
||||
expect.step("element is now visible");
|
||||
},
|
||||
},
|
||||
],
|
||||
onError: (error) => {
|
||||
expect.step(error.message);
|
||||
},
|
||||
});
|
||||
macro.start(queryOne(".counter"));
|
||||
await waitForMacro();
|
||||
expect.verifySteps(["TIMEOUT step failed to complete within 1000 ms."]);
|
||||
});
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { beforeEach, expect, onError, test } from "@odoo/hoot";
|
||||
import { animationFrame, Deferred } from "@odoo/hoot-mock";
|
||||
import { clearRegistry, mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { Component, onWillStart, useState, xml } from "@odoo/owl";
|
||||
|
||||
const mainComponentsRegistry = registry.category("main_components");
|
||||
|
||||
beforeEach(async () => {
|
||||
clearRegistry(mainComponentsRegistry);
|
||||
});
|
||||
|
||||
test("simple rendering", async () => {
|
||||
class MainComponentA extends Component {
|
||||
static template = xml`<span>MainComponentA</span>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
class MainComponentB extends Component {
|
||||
static template = xml`<span>MainComponentB</span>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
mainComponentsRegistry.add("MainComponentA", { Component: MainComponentA, props: {} });
|
||||
mainComponentsRegistry.add("MainComponentB", { Component: MainComponentB, props: {} });
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
expect("div.o-main-components-container").toHaveCount(1);
|
||||
expect(".o-main-components-container").toHaveInnerHTML(`
|
||||
<span>MainComponentA</span>
|
||||
<span>MainComponentB</span>
|
||||
<div class="o-overlay-container"></div>
|
||||
<div></div>
|
||||
<div class="o_notification_manager"></div>
|
||||
`);
|
||||
});
|
||||
|
||||
test("unmounts erroring main component", async () => {
|
||||
expect.assertions(6);
|
||||
expect.errors(1);
|
||||
onError((error) => {
|
||||
expect.step(error.reason.message);
|
||||
expect.step(error.reason.cause.message);
|
||||
});
|
||||
let compA;
|
||||
class MainComponentA extends Component {
|
||||
static template = xml`<span><t t-if="state.shouldThrow" t-esc="error"/>MainComponentA</span>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
compA = this;
|
||||
this.state = useState({ shouldThrow: false });
|
||||
}
|
||||
get error() {
|
||||
throw new Error("BOOM");
|
||||
}
|
||||
}
|
||||
|
||||
class MainComponentB extends Component {
|
||||
static template = xml`<span>MainComponentB</span>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
mainComponentsRegistry.add("MainComponentA", { Component: MainComponentA, props: {} });
|
||||
mainComponentsRegistry.add("MainComponentB", { Component: MainComponentB, props: {} });
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
expect("div.o-main-components-container").toHaveCount(1);
|
||||
expect(".o-main-components-container").toHaveInnerHTML(`
|
||||
<span>MainComponentA</span><span>MainComponentB</span>
|
||||
<div class="o-overlay-container"></div>
|
||||
<div></div>
|
||||
<div class="o_notification_manager"></div>
|
||||
`);
|
||||
compA.state.shouldThrow = true;
|
||||
await animationFrame();
|
||||
expect.verifySteps([
|
||||
'An error occured in the owl lifecycle (see this Error\'s "cause" property)',
|
||||
"BOOM",
|
||||
]);
|
||||
expect.verifyErrors(["BOOM"]);
|
||||
|
||||
expect(".o-main-components-container span").toHaveCount(1);
|
||||
expect(".o-main-components-container span").toHaveInnerHTML("MainComponentB");
|
||||
});
|
||||
|
||||
test("unmounts erroring main component: variation", async () => {
|
||||
expect.assertions(6);
|
||||
expect.errors(1);
|
||||
onError((error) => {
|
||||
expect.step(error.reason.message);
|
||||
expect.step(error.reason.cause.message);
|
||||
});
|
||||
class MainComponentA extends Component {
|
||||
static template = xml`<span>MainComponentA</span>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
let compB;
|
||||
class MainComponentB extends Component {
|
||||
static template = xml`<span><t t-if="state.shouldThrow" t-esc="error"/>MainComponentB</span>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
compB = this;
|
||||
this.state = useState({ shouldThrow: false });
|
||||
}
|
||||
get error() {
|
||||
throw new Error("BOOM");
|
||||
}
|
||||
}
|
||||
|
||||
mainComponentsRegistry.add("MainComponentA", { Component: MainComponentA, props: {} });
|
||||
mainComponentsRegistry.add("MainComponentB", { Component: MainComponentB, props: {} });
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
expect("div.o-main-components-container").toHaveCount(1);
|
||||
expect(".o-main-components-container").toHaveInnerHTML(`
|
||||
<span>MainComponentA</span><span>MainComponentB</span>
|
||||
<div class="o-overlay-container"></div>
|
||||
<div></div>
|
||||
<div class="o_notification_manager"></div>
|
||||
`);
|
||||
compB.state.shouldThrow = true;
|
||||
await animationFrame();
|
||||
expect.verifySteps([
|
||||
'An error occured in the owl lifecycle (see this Error\'s "cause" property)',
|
||||
"BOOM",
|
||||
]);
|
||||
expect.verifyErrors(["BOOM"]);
|
||||
expect(".o-main-components-container span").toHaveCount(1);
|
||||
expect(".o-main-components-container span").toHaveInnerHTML("MainComponentA");
|
||||
});
|
||||
|
||||
test("MainComponentsContainer re-renders when the registry changes", async () => {
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
|
||||
expect(".myMainComponent").toHaveCount(0);
|
||||
class MyMainComponent extends Component {
|
||||
static template = xml`<div class="myMainComponent" />`;
|
||||
static props = ["*"];
|
||||
}
|
||||
mainComponentsRegistry.add("myMainComponent", { Component: MyMainComponent });
|
||||
await animationFrame();
|
||||
expect(".myMainComponent").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Should be possible to add a new component when MainComponentContainer is not mounted yet", async () => {
|
||||
const defer = new Deferred();
|
||||
patchWithCleanup(MainComponentsContainer.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
onWillStart(async () => {
|
||||
await defer;
|
||||
});
|
||||
},
|
||||
});
|
||||
mountWithCleanup(MainComponentsContainer);
|
||||
class MyMainComponent extends Component {
|
||||
static template = xml`<div class="myMainComponent" />`;
|
||||
static props = ["*"];
|
||||
}
|
||||
// Wait for the setup of MainComponentsContainer to be completed
|
||||
await animationFrame();
|
||||
mainComponentsRegistry.add("myMainComponent", { Component: MyMainComponent });
|
||||
// Release the component mounting
|
||||
defer.resolve();
|
||||
await animationFrame();
|
||||
expect(".myMainComponent").toHaveCount(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,921 @@
|
|||
import { expect, getFixture, test } from "@odoo/hoot";
|
||||
import { queryAllTexts } from "@odoo/hoot-dom";
|
||||
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import {
|
||||
clickPrev,
|
||||
followRelation,
|
||||
getDisplayedFieldNames,
|
||||
getFocusedFieldName,
|
||||
getModelFieldSelectorValues,
|
||||
getTitle,
|
||||
openModelFieldSelectorPopover,
|
||||
} from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
models,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
|
||||
|
||||
class Partner extends models.Model {
|
||||
foo = fields.Char();
|
||||
bar = fields.Boolean();
|
||||
product_id = fields.Many2one({ relation: "product" });
|
||||
json_field = fields.Json();
|
||||
|
||||
_records = [
|
||||
{ id: 1, foo: "yop", bar: true, product_id: 37 },
|
||||
{ id: 2, foo: "blip", bar: true, product_id: false },
|
||||
{ id: 4, foo: "abc", bar: false, product_id: 41 },
|
||||
];
|
||||
}
|
||||
|
||||
class Product extends models.Model {
|
||||
name = fields.Char({ string: "Product Name" });
|
||||
|
||||
_records = [
|
||||
{ id: 37, name: "xphone" },
|
||||
{ id: 41, name: "xpad" },
|
||||
];
|
||||
}
|
||||
|
||||
defineModels([Partner, Product]);
|
||||
|
||||
function addProperties() {
|
||||
Partner._fields.properties = fields.Properties({
|
||||
string: "Properties",
|
||||
definition_record: "product_id",
|
||||
definition_record_field: "definitions",
|
||||
});
|
||||
Product._fields.definitions = fields.PropertiesDefinition({
|
||||
string: "Definitions",
|
||||
});
|
||||
Product._records[0].definitions = [
|
||||
{ name: "xphone_prop_1", string: "P1", type: "boolean" },
|
||||
{ name: "xphone_prop_2", string: "P2", type: "char" },
|
||||
];
|
||||
Product._records[1].definitions = [{ name: "xpad_prop_1", string: "P1", type: "date" }];
|
||||
}
|
||||
|
||||
test("creating a field chain from scratch", async () => {
|
||||
const getValueFromDOM = (root) =>
|
||||
queryAllTexts(".o_model_field_selector_chain_part", { root }).join(" -> ");
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { ModelFieldSelector };
|
||||
static template = xml`
|
||||
<ModelFieldSelector
|
||||
readonly="false"
|
||||
resModel="'partner'"
|
||||
path="path"
|
||||
isDebugMode="false"
|
||||
update="(path) => this.onUpdate(path)"
|
||||
/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.path = "";
|
||||
}
|
||||
onUpdate(path) {
|
||||
expect.step(`update: ${path}`);
|
||||
this.path = path;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
const fieldSelector = await mountWithCleanup(Parent);
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
expect("input.o_input[placeholder='Search...']").toBeFocused();
|
||||
expect(".o_model_field_selector_popover").toHaveCount(1);
|
||||
|
||||
// The field selector popover should contain the list of "partner"
|
||||
// fields. "Bar" should be among them.
|
||||
expect(".o_model_field_selector_popover_item_name:first").toHaveText("Bar");
|
||||
|
||||
// Clicking the "Bar" field should close the popover and set the field
|
||||
// chain to "bar" as it is a basic field
|
||||
await contains(".o_model_field_selector_popover_item_name").click();
|
||||
expect(".o_model_field_selector_popover").toHaveCount(0);
|
||||
expect(getValueFromDOM()).toBe("Bar");
|
||||
expect(fieldSelector.path).toBe("bar");
|
||||
expect.verifySteps(["update: bar"]);
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(".o_model_field_selector_popover").toHaveCount(1);
|
||||
// The field selector popover should contain the list of "partner"
|
||||
// fields. "Product" should be among them.
|
||||
expect(
|
||||
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon"
|
||||
).toHaveCount(1, { message: "field selector popover should contain the 'Product' field" });
|
||||
|
||||
// Clicking on the "Product" field should update the popover to show
|
||||
// the product fields (so only "Product Name" and the default fields should be there)
|
||||
await contains(
|
||||
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon"
|
||||
).click();
|
||||
expect(".o_model_field_selector_popover_item_name").toHaveCount(5);
|
||||
expect(queryAllTexts(".o_model_field_selector_popover_item_name").at(-1)).toBe("Product Name", {
|
||||
message: "the name of the last suggestion should be 'Product Name'",
|
||||
});
|
||||
await contains(".o_model_field_selector_popover_item_name:last").click();
|
||||
expect(".o_model_field_selector_popover").toHaveCount(0);
|
||||
expect(getValueFromDOM()).toBe("Product -> Product Name");
|
||||
expect.verifySteps(["update: product_id.name"]);
|
||||
|
||||
// Remove the current selection and recreate it again
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(".o_model_field_selector_popover_prev_page").click();
|
||||
await contains(".o_model_field_selector_popover_close").click();
|
||||
expect.verifySteps(["update: product_id"]);
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(
|
||||
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon"
|
||||
).toHaveCount(1);
|
||||
|
||||
await contains(
|
||||
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon"
|
||||
).click();
|
||||
await contains(".o_model_field_selector_popover_item_name:last").click();
|
||||
expect(".o_model_field_selector_popover").toHaveCount(0);
|
||||
expect(getValueFromDOM()).toBe("Product -> Product Name");
|
||||
expect.verifySteps(["update: product_id.name"]);
|
||||
});
|
||||
|
||||
test("default field chain should set the page data correctly", async () => {
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
path: "product_id",
|
||||
resModel: "partner",
|
||||
isDebugMode: false,
|
||||
},
|
||||
});
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(".o_model_field_selector_popover").toHaveCount(1);
|
||||
expect(getDisplayedFieldNames()).toEqual([
|
||||
"Bar",
|
||||
"Created on",
|
||||
"Display name",
|
||||
"Foo",
|
||||
"Id",
|
||||
"Last Modified on",
|
||||
"Product",
|
||||
]);
|
||||
expect(".o_model_field_selector_popover_item:last").toHaveClass("active");
|
||||
});
|
||||
|
||||
test("use the filter option", async () => {
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
path: "",
|
||||
resModel: "partner",
|
||||
filter: (field) => field.type === "many2one" && field.searchable,
|
||||
},
|
||||
});
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(getDisplayedFieldNames()).toEqual(["Product"]);
|
||||
});
|
||||
|
||||
test("default `showSearchInput` option", async () => {
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
path: "",
|
||||
resModel: "partner",
|
||||
},
|
||||
});
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(".o_model_field_selector_popover .o_model_field_selector_popover_search").toHaveCount(1);
|
||||
expect(getDisplayedFieldNames()).toEqual([
|
||||
"Bar",
|
||||
"Created on",
|
||||
"Display name",
|
||||
"Foo",
|
||||
"Id",
|
||||
"Last Modified on",
|
||||
"Product",
|
||||
]);
|
||||
|
||||
// search 'xx'
|
||||
await contains(
|
||||
".o_model_field_selector_popover .o_model_field_selector_popover_search input"
|
||||
).edit("xx", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(getDisplayedFieldNames()).toBeEmpty();
|
||||
|
||||
// search 'Pro'
|
||||
await contains(
|
||||
".o_model_field_selector_popover .o_model_field_selector_popover_search input"
|
||||
).edit("Pro", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(getDisplayedFieldNames()).toEqual(["Product"]);
|
||||
});
|
||||
|
||||
test("false `showSearchInput` option", async () => {
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
showSearchInput: false,
|
||||
path: "",
|
||||
resModel: "partner",
|
||||
},
|
||||
});
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(".o_model_field_selector_popover .o_model_field_selector_popover_search").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("create a field chain with value 1 i.e. TRUE_LEAF", async () => {
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
showSearchInput: false,
|
||||
path: 1,
|
||||
resModel: "partner",
|
||||
},
|
||||
});
|
||||
expect(".o_model_field_selector_chain_part").toHaveText("1");
|
||||
});
|
||||
|
||||
test("create a field chain with value 0 i.e. FALSE_LEAF", async () => {
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
showSearchInput: false,
|
||||
path: 0,
|
||||
resModel: "partner",
|
||||
},
|
||||
});
|
||||
expect(".o_model_field_selector_chain_part").toHaveText("0", {
|
||||
message: "field name value should be 0.",
|
||||
});
|
||||
});
|
||||
|
||||
test("cache fields_get", async () => {
|
||||
Partner._fields.partner_id = fields.Many2one({
|
||||
string: "Partner",
|
||||
relation: "partner",
|
||||
});
|
||||
|
||||
onRpc("fields_get", ({ method }) => expect.step(method));
|
||||
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
path: "partner_id.partner_id.partner_id.foo",
|
||||
resModel: "partner",
|
||||
},
|
||||
});
|
||||
expect.verifySteps(["fields_get"]);
|
||||
});
|
||||
|
||||
test("Using back button in popover", async () => {
|
||||
Partner._fields.partner_id = fields.Many2one({
|
||||
string: "Partner",
|
||||
relation: "partner",
|
||||
});
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { ModelFieldSelector };
|
||||
static template = xml`
|
||||
<ModelFieldSelector
|
||||
readonly="false"
|
||||
resModel="'partner'"
|
||||
path="path"
|
||||
update="(path) => this.onUpdate(path)"
|
||||
/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.path = "partner_id.foo";
|
||||
}
|
||||
onUpdate(path) {
|
||||
this.path = path;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Partner", "Foo"]);
|
||||
expect(".o_model_field_selector i.o_model_field_selector_warning").toHaveCount(0);
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(".o_model_field_selector_popover_prev_page").click();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Partner"]);
|
||||
expect(".o_model_field_selector i.o_model_field_selector_warning").toHaveCount(0);
|
||||
|
||||
await contains(
|
||||
".o_model_field_selector_popover_item:nth-child(1) .o_model_field_selector_popover_item_name"
|
||||
).click();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Bar"]);
|
||||
expect(".o_model_field_selector_popover").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("select a relational field does not follow relation", async () => {
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
path: "",
|
||||
resModel: "partner",
|
||||
update(path) {
|
||||
expect.step(path);
|
||||
},
|
||||
},
|
||||
});
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(
|
||||
".o_model_field_selector_popover_item:last-child .o_model_field_selector_popover_relation_icon"
|
||||
).toHaveCount(1);
|
||||
|
||||
await contains(
|
||||
".o_model_field_selector_popover_item:last-child .o_model_field_selector_popover_item_name"
|
||||
).click();
|
||||
expect.verifySteps(["product_id"]);
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(getDisplayedFieldNames()).toEqual([
|
||||
"Bar",
|
||||
"Created on",
|
||||
"Display name",
|
||||
"Foo",
|
||||
"Id",
|
||||
"Last Modified on",
|
||||
"Product",
|
||||
]);
|
||||
expect(".o_model_field_selector_popover_relation_icon").toHaveCount(1);
|
||||
|
||||
await contains(".o_model_field_selector_popover_relation_icon").click();
|
||||
expect(getDisplayedFieldNames()).toEqual([
|
||||
"Created on",
|
||||
"Display name",
|
||||
"Id",
|
||||
"Last Modified on",
|
||||
"Product Name",
|
||||
]);
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
|
||||
await contains(".o_model_field_selector_popover_item_name").click();
|
||||
expect.verifySteps(["product_id.create_date"]);
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("can follow relations", async () => {
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
path: "",
|
||||
resModel: "partner",
|
||||
followRelations: true, // default
|
||||
update(path) {
|
||||
expect(path).toBe("product_id");
|
||||
},
|
||||
},
|
||||
});
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(getDisplayedFieldNames()).toEqual([
|
||||
"Bar",
|
||||
"Created on",
|
||||
"Display name",
|
||||
"Foo",
|
||||
"Id",
|
||||
"Last Modified on",
|
||||
"Product",
|
||||
]);
|
||||
expect(".o_model_field_selector_popover_relation_icon").toHaveCount(1);
|
||||
|
||||
await contains(".o_model_field_selector_popover_relation_icon").click();
|
||||
expect(getDisplayedFieldNames()).toEqual([
|
||||
"Created on",
|
||||
"Display name",
|
||||
"Id",
|
||||
"Last Modified on",
|
||||
"Product Name",
|
||||
]);
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("cannot follow relations", async () => {
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
path: "",
|
||||
resModel: "partner",
|
||||
followRelations: false,
|
||||
update(path) {
|
||||
expect(path).toBe("product_id");
|
||||
},
|
||||
},
|
||||
});
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(getDisplayedFieldNames()).toEqual([
|
||||
"Bar",
|
||||
"Created on",
|
||||
"Display name",
|
||||
"Foo",
|
||||
"Id",
|
||||
"Last Modified on",
|
||||
"Product",
|
||||
]);
|
||||
expect(".o_model_field_selector_popover_relation_icon").toHaveCount(0);
|
||||
await contains(".o_model_field_selector_popover_item_name:last").click();
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Product"]);
|
||||
});
|
||||
|
||||
test("Edit path in popover debug input", async () => {
|
||||
Partner._fields.partner_id = fields.Many2one({
|
||||
string: "Partner",
|
||||
relation: "partner",
|
||||
});
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { ModelFieldSelector };
|
||||
static template = xml`
|
||||
<ModelFieldSelector
|
||||
readonly="false"
|
||||
resModel="'partner'"
|
||||
path="path"
|
||||
isDebugMode="true"
|
||||
update="(pathInfo) => this.onUpdate(pathInfo)"
|
||||
/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.path = "foo";
|
||||
}
|
||||
onUpdate(path) {
|
||||
this.path = path;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Foo"]);
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(".o_model_field_selector_popover .o_model_field_selector_debug").edit(
|
||||
"partner_id.bar"
|
||||
);
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Partner", "Bar"]);
|
||||
});
|
||||
|
||||
test("title on first four pages", async () => {
|
||||
class Turtle extends models.Model {
|
||||
mother_id = fields.Many2one({
|
||||
string: "Mother",
|
||||
relation: "turtle",
|
||||
});
|
||||
}
|
||||
defineModels([Turtle]);
|
||||
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
path: "mother_id",
|
||||
resModel: "turtle",
|
||||
},
|
||||
});
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(getTitle()).toBe("Select a field");
|
||||
|
||||
await followRelation();
|
||||
expect(getTitle()).toBe("Mother");
|
||||
|
||||
await followRelation();
|
||||
expect(getTitle()).toBe("... > Mother");
|
||||
|
||||
await followRelation();
|
||||
expect(getTitle()).toBe("... > Mother");
|
||||
});
|
||||
|
||||
test("start on complex path and click prev", async () => {
|
||||
class Turtle extends models.Model {
|
||||
mother_id = fields.Many2one({
|
||||
string: "Mother",
|
||||
relation: "turtle",
|
||||
});
|
||||
father_id = fields.Many2one({
|
||||
string: "Father",
|
||||
relation: "turtle",
|
||||
});
|
||||
}
|
||||
defineModels([Turtle]);
|
||||
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
path: "mother_id.father_id.mother_id",
|
||||
resModel: "turtle",
|
||||
},
|
||||
});
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
// viewing third page
|
||||
// mother is selected on that page
|
||||
expect(getTitle()).toBe("... > Father");
|
||||
expect(getFocusedFieldName()).toBe("Mother");
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Mother", "Father", "Mother"]);
|
||||
|
||||
// select Father on third page and go to next page
|
||||
// no selection on fourth page --> first item is focused
|
||||
await followRelation();
|
||||
expect(getTitle()).toBe("... > Father");
|
||||
expect(getFocusedFieldName()).toBe("Created on");
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Mother", "Father", "Father"]);
|
||||
|
||||
// go back to third page. Nothing has changed
|
||||
await clickPrev();
|
||||
expect(getTitle()).toBe("... > Father");
|
||||
expect(getFocusedFieldName()).toBe("Father");
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Mother", "Father", "Father"]);
|
||||
|
||||
// go back to second page. Nothing has changed.
|
||||
await clickPrev();
|
||||
expect(getTitle()).toBe("Mother");
|
||||
expect(getFocusedFieldName()).toBe("Father");
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Mother", "Father"]);
|
||||
|
||||
// go back to first page. Nothing has changed.
|
||||
await clickPrev();
|
||||
expect(getTitle()).toBe("Select a field");
|
||||
expect(getFocusedFieldName()).toBe("Mother");
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Mother"]);
|
||||
expect(".o_model_field_selector_popover_prev_page").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("support of invalid paths (allowEmpty=false)", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { ModelFieldSelector };
|
||||
static template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" path="state.path" />`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ path: `` });
|
||||
}
|
||||
}
|
||||
|
||||
const parent = await mountWithCleanup(Parent);
|
||||
expect(getModelFieldSelectorValues()).toEqual(["-"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
|
||||
parent.state.path = undefined;
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["-"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
|
||||
parent.state.path = false;
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["-"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
|
||||
parent.state.path = {};
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["-"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
|
||||
parent.state.path = `a`;
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["a"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
|
||||
parent.state.path = `foo.a`;
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Foo", "a"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
|
||||
parent.state.path = `a.foo`;
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["a", "foo"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("support of invalid paths (allowEmpty=true)", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { ModelFieldSelector };
|
||||
static template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" path="state.path" allowEmpty="true" />`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ path: `` });
|
||||
}
|
||||
}
|
||||
|
||||
const parent = await mountWithCleanup(Parent);
|
||||
expect(getModelFieldSelectorValues()).toEqual([]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(0);
|
||||
|
||||
parent.state.path = undefined;
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual([]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(0);
|
||||
|
||||
parent.state.path = false;
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual([]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(0);
|
||||
|
||||
parent.state.path = {};
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["-"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
|
||||
parent.state.path = `a`;
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["a"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
|
||||
parent.state.path = `foo.a`;
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Foo", "a"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
|
||||
parent.state.path = `a.foo`;
|
||||
await animationFrame();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["a", "foo"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("debug input", async () => {
|
||||
expect.assertions(10);
|
||||
let num = 1;
|
||||
class Parent extends Component {
|
||||
static components = { ModelFieldSelector };
|
||||
static template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" isDebugMode="true" path="state.path" update.bind="update"/>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ path: `` });
|
||||
}
|
||||
update(path, fieldInfo) {
|
||||
if (num === 1) {
|
||||
expect(path).toBe("a");
|
||||
expect(fieldInfo).toEqual({
|
||||
fieldDef: null,
|
||||
resModel: "partner",
|
||||
});
|
||||
num++;
|
||||
} else {
|
||||
expect(path).toBe("foo");
|
||||
expect(fieldInfo).toEqual({
|
||||
resModel: "partner",
|
||||
fieldDef: {
|
||||
string: "Foo",
|
||||
readonly: false,
|
||||
required: false,
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
store: true,
|
||||
groupable: true,
|
||||
type: "char",
|
||||
name: "foo",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(getModelFieldSelectorValues()).toEqual(["-"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(".o_model_field_selector_debug").edit("a", { confirm: false });
|
||||
await contains(".o_model_field_selector_popover_search").click();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["a"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
|
||||
await contains(".o_model_field_selector_popover_close").click();
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(".o_model_field_selector_debug").edit("foo");
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Foo"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(0);
|
||||
|
||||
await contains(".o_model_field_selector_popover_close").click();
|
||||
});
|
||||
|
||||
test("focus on search input", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { ModelFieldSelector };
|
||||
static template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" path="state.path" update.bind="update"/>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ path: `foo` });
|
||||
}
|
||||
update() {}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(".o_model_field_selector_popover_search .o_input").toBeFocused();
|
||||
|
||||
await followRelation();
|
||||
expect(".o_model_field_selector_popover_search .o_input").toBeFocused();
|
||||
});
|
||||
|
||||
test("support properties", async () => {
|
||||
addProperties();
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { ModelFieldSelector };
|
||||
static template = xml`
|
||||
<ModelFieldSelector
|
||||
readonly="false"
|
||||
resModel="'partner'"
|
||||
path="path"
|
||||
isDebugMode="true"
|
||||
update="(path, fieldInfo) => this.onUpdate(path)"
|
||||
/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.path = "foo";
|
||||
}
|
||||
onUpdate(path) {
|
||||
this.path = path;
|
||||
expect.step(path);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(getTitle()).toBe("Select a field");
|
||||
expect('.o_model_field_selector_popover_item[data-name="properties"]').toHaveCount(1);
|
||||
expect(
|
||||
'.o_model_field_selector_popover_item[data-name="properties"] .o_model_field_selector_popover_relation_icon'
|
||||
).toHaveCount(1);
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Foo"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(0);
|
||||
|
||||
await contains(
|
||||
'.o_model_field_selector_popover_item[data-name="properties"] .o_model_field_selector_popover_relation_icon'
|
||||
).click();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Properties"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
expect.verifySteps([]);
|
||||
|
||||
await clickPrev();
|
||||
expect(getTitle()).toBe("Select a field");
|
||||
await contains(
|
||||
'.o_model_field_selector_popover_item[data-name="properties"] .o_model_field_selector_popover_item_name'
|
||||
).click();
|
||||
expect(getTitle()).toBe("Properties");
|
||||
expect(".o_model_field_selector_value").toHaveText("Properties");
|
||||
expect(".o_model_field_selector_popover_item").toHaveCount(3);
|
||||
expect('.o_model_field_selector_popover_item[data-name="xphone_prop_1"]').toHaveCount(1);
|
||||
expect('.o_model_field_selector_popover_item[data-name="xphone_prop_2"]').toHaveCount(1);
|
||||
expect('.o_model_field_selector_popover_item[data-name="xpad_prop_1"]').toHaveCount(1);
|
||||
expect(getDisplayedFieldNames()).toEqual([
|
||||
"P1 (xphone)\nxphone_prop_1 (boolean)",
|
||||
"P1 (xpad)\nxpad_prop_1 (date)",
|
||||
"P2 (xphone)\nxphone_prop_2 (char)",
|
||||
]);
|
||||
|
||||
await contains(
|
||||
'.o_model_field_selector_popover_item[data-name="xphone_prop_2"] .o_model_field_selector_popover_item_name'
|
||||
).click();
|
||||
expect.verifySteps(["properties.xphone_prop_2"]);
|
||||
expect(".o_model_field_selector_value").toHaveText("PropertiesP2");
|
||||
expect(".o_model_field_selector_warning").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("search on field string and name in debug mode", async () => {
|
||||
Partner._fields.ucit = fields.Char({
|
||||
type: "char",
|
||||
string: "Some string",
|
||||
});
|
||||
class Parent extends Component {
|
||||
static components = { ModelFieldSelector };
|
||||
static template = xml`
|
||||
<ModelFieldSelector
|
||||
readonly="false"
|
||||
resModel="'partner'"
|
||||
path="'foo'"
|
||||
isDebugMode="true"
|
||||
/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(
|
||||
".o_model_field_selector_popover .o_model_field_selector_popover_search input"
|
||||
).edit("uct", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(getDisplayedFieldNames()).toEqual([
|
||||
"Product\nproduct_id (many2one)",
|
||||
"Some string\nucit (char)",
|
||||
]);
|
||||
});
|
||||
|
||||
test("clear button (allowEmpty=true)", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { ModelFieldSelector };
|
||||
static template = xml`
|
||||
<ModelFieldSelector
|
||||
readonly="false"
|
||||
resModel="'partner'"
|
||||
path="path"
|
||||
allowEmpty="true"
|
||||
isDebugMode="true"
|
||||
update="(path, fieldInfo) => this.onUpdate(path)"
|
||||
/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.path = "baaarrr";
|
||||
}
|
||||
onUpdate(path) {
|
||||
this.path = path;
|
||||
expect.step(`path is ${JSON.stringify(path)}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(getModelFieldSelectorValues()).toEqual(["baaarrr"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(1);
|
||||
expect(".o_model_field_selector .fa.fa-times").toHaveCount(1);
|
||||
|
||||
// clear when popover is not open
|
||||
await contains(".o_model_field_selector .fa.fa-times").click();
|
||||
expect(getModelFieldSelectorValues()).toEqual([]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(0);
|
||||
expect(".o_model_field_selector .fa.fa-times").toHaveCount(0);
|
||||
expect.verifySteps([`path is ""`]);
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(".o_model_field_selector_popover_item_name").click();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Bar"]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(0);
|
||||
expect(".o_model_field_selector .fa.fa-times").toHaveCount(1);
|
||||
expect.verifySteps([`path is "bar"`]);
|
||||
|
||||
// clear when popover is open
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(".o_model_field_selector .fa.fa-times").click();
|
||||
expect(getModelFieldSelectorValues()).toEqual([]);
|
||||
expect(".o_model_field_selector_warning").toHaveCount(0);
|
||||
expect(".o_model_field_selector .fa.fa-times").toHaveCount(0);
|
||||
expect.verifySteps([`path is ""`]);
|
||||
});
|
||||
|
||||
test("Modify path in popover debug input and click away", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { ModelFieldSelector };
|
||||
static template = xml`
|
||||
<ModelFieldSelector
|
||||
readonly="false"
|
||||
resModel="'partner'"
|
||||
path="path"
|
||||
isDebugMode="true"
|
||||
update.bind="update"
|
||||
/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.path = "foo";
|
||||
}
|
||||
update(path) {
|
||||
this.path = path;
|
||||
expect.step(path);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Foo"]);
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(".o_model_field_selector_popover .o_model_field_selector_debug").edit(
|
||||
"foooooo",
|
||||
{ confirm: false }
|
||||
);
|
||||
expect(getModelFieldSelectorValues()).toEqual(["Foo"]);
|
||||
|
||||
await contains(getFixture()).click();
|
||||
expect(getModelFieldSelectorValues()).toEqual(["foooooo"]);
|
||||
expect.verifySteps(["foooooo"]);
|
||||
});
|
||||
|
||||
test("showDebugInput = false", async () => {
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
path: "product_id",
|
||||
resModel: "partner",
|
||||
isDebugMode: true,
|
||||
showDebugInput: false,
|
||||
},
|
||||
});
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
expect(".o_model_field_selector_debug").toHaveCount(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { queryAll } from "@odoo/hoot-dom";
|
||||
import { runAllTimers } from "@odoo/hoot-mock";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
models,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { ModelSelector } from "@web/core/model_selector/model_selector";
|
||||
|
||||
async function mountModelSelector(models = [], value = undefined, onModelSelected = () => {}) {
|
||||
await mountWithCleanup(ModelSelector, {
|
||||
props: {
|
||||
models,
|
||||
value,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
class IrModel extends models.Model {
|
||||
_name = "ir.model";
|
||||
|
||||
name = fields.Char({ string: "Model Name" });
|
||||
model = fields.Char();
|
||||
_records = [
|
||||
{ id: 1, name: "Model 1", model: "model_1" },
|
||||
{ id: 2, name: "Model 2", model: "model_2" },
|
||||
{ id: 3, name: "Model 3", model: "model_3" },
|
||||
{ id: 4, name: "Model 4", model: "model_4" },
|
||||
{ id: 5, name: "Model 5", model: "model_5" },
|
||||
{ id: 6, name: "Model 6", model: "model_6" },
|
||||
{ id: 7, name: "Model 7", model: "model_7" },
|
||||
{ id: 8, name: "Model 8", model: "model_8" },
|
||||
{ id: 9, name: "Model 9", model: "model_9" },
|
||||
{ id: 10, name: "Model 10", model: "model_10" },
|
||||
];
|
||||
}
|
||||
|
||||
defineModels([IrModel]);
|
||||
describe.current.tags("desktop");
|
||||
|
||||
onRpc("ir.model", "display_name_for", function ({ args }) {
|
||||
const models = args[0];
|
||||
const records = this.env["ir.model"].filter((rec) => models.includes(rec.model));
|
||||
return records.map((record) => ({
|
||||
model: record.model,
|
||||
display_name: record.name,
|
||||
}));
|
||||
});
|
||||
|
||||
test("model_selector: with no model", async () => {
|
||||
await mountModelSelector();
|
||||
await contains(".o-autocomplete--input").click();
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveCount(1);
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveText("No records");
|
||||
});
|
||||
|
||||
test("model_selector: displays model display names", async () => {
|
||||
await mountModelSelector(["model_1", "model_2", "model_3"]);
|
||||
await contains(".o-autocomplete--input").click();
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveCount(3);
|
||||
const items = queryAll("li.o-autocomplete--dropdown-item");
|
||||
expect(items[0]).toHaveText("Model 1");
|
||||
expect(items[1]).toHaveText("Model 2");
|
||||
expect(items[2]).toHaveText("Model 3");
|
||||
});
|
||||
|
||||
test("model_selector: with 8 models", async () => {
|
||||
await mountModelSelector([
|
||||
"model_1",
|
||||
"model_2",
|
||||
"model_3",
|
||||
"model_4",
|
||||
"model_5",
|
||||
"model_6",
|
||||
"model_7",
|
||||
"model_8",
|
||||
]);
|
||||
await contains(".o-autocomplete--input").click();
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveCount(8);
|
||||
});
|
||||
|
||||
test("model_selector: with more than 8 models", async () => {
|
||||
await mountModelSelector([
|
||||
"model_1",
|
||||
"model_2",
|
||||
"model_3",
|
||||
"model_4",
|
||||
"model_5",
|
||||
"model_6",
|
||||
"model_7",
|
||||
"model_8",
|
||||
"model_9",
|
||||
"model_10",
|
||||
]);
|
||||
await contains(".o-autocomplete--input").click();
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveCount(9);
|
||||
expect("li.o-autocomplete--dropdown-item:eq(8)").toHaveText("Start typing...");
|
||||
});
|
||||
|
||||
test("model_selector: search content is not applied when opening the autocomplete", async () => {
|
||||
await mountModelSelector(["model_1", "model_2"], "_2");
|
||||
await contains(".o-autocomplete--input").click();
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveCount(2);
|
||||
});
|
||||
|
||||
test("model_selector: with search matching some records on technical name", async () => {
|
||||
await mountModelSelector(["model_1", "model_2"]);
|
||||
await contains(".o-autocomplete--input").click();
|
||||
await contains(".o-autocomplete--input").edit("_2", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveCount(1);
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveText("Model 2");
|
||||
});
|
||||
|
||||
test("model_selector: with search matching some records on business name", async () => {
|
||||
await mountModelSelector(["model_1", "model_2"]);
|
||||
await contains(".o-autocomplete--input").click();
|
||||
await contains(".o-autocomplete--input").edit(" 2", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveCount(1);
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveText("Model 2");
|
||||
});
|
||||
|
||||
test("model_selector: with search matching no record", async () => {
|
||||
await mountModelSelector(["model_1", "model_2"]);
|
||||
await contains(".o-autocomplete--input").edit("a random search query", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveCount(1);
|
||||
expect("li.o-autocomplete--dropdown-item").toHaveText("No records");
|
||||
});
|
||||
|
||||
test("model_selector: select a model", async () => {
|
||||
await mountModelSelector(["model_1", "model_2", "model_3"], "Model 1", (selected) => {
|
||||
expect.step("model selected");
|
||||
expect(selected).toEqual({
|
||||
label: "Model 2",
|
||||
technical: "model_2",
|
||||
});
|
||||
});
|
||||
await contains(".o-autocomplete--input").click();
|
||||
await contains(".o_model_selector_model_2").click();
|
||||
expect.verifySteps(["model selected"]);
|
||||
});
|
||||
|
||||
test("model_selector: click on start typing", async () => {
|
||||
await mountModelSelector([
|
||||
"model_1",
|
||||
"model_2",
|
||||
"model_3",
|
||||
"model_4",
|
||||
"model_5",
|
||||
"model_6",
|
||||
"model_7",
|
||||
"model_8",
|
||||
"model_9",
|
||||
"model_10",
|
||||
]);
|
||||
await contains(".o-autocomplete--input").click();
|
||||
await contains("li.o-autocomplete--dropdown-item:eq(8)").click();
|
||||
expect(".o-autocomplete--input").toHaveValue("");
|
||||
expect(".o-autocomplete.dropdown ul").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("model_selector: with an initial value", async () => {
|
||||
await mountModelSelector(["model_1", "model_2", "model_3"], "Model 1");
|
||||
expect(".o-autocomplete--input").toHaveValue("Model 1");
|
||||
});
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { queryAllTexts } from "@odoo/hoot-dom";
|
||||
import { contains, mountWithCleanup, onRpc } from "@web/../tests/web_test_helpers";
|
||||
import { NameAndSignature } from "@web/core/signature/name_and_signature";
|
||||
|
||||
const getNameAndSignatureButtonNames = () => {
|
||||
return queryAllTexts(".card-header .col-auto").filter((text) => text.length);
|
||||
};
|
||||
|
||||
onRpc("/web/sign/get_fonts/", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
test("test name_and_signature widget", async () => {
|
||||
const props = {
|
||||
signature: {
|
||||
name: "Don Toliver",
|
||||
},
|
||||
};
|
||||
await mountWithCleanup(NameAndSignature, { props });
|
||||
expect(getNameAndSignatureButtonNames()).toEqual(["Auto", "Draw", "Load"]);
|
||||
expect(".o_web_sign_auto_select_style").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveText("Auto");
|
||||
expect(".o_web_sign_name_group input").toHaveCount(1);
|
||||
expect(".o_web_sign_name_group input").toHaveValue("Don Toliver");
|
||||
|
||||
await contains(".o_web_sign_draw_button").click();
|
||||
expect(getNameAndSignatureButtonNames()).toEqual(["Auto", "Draw", "Load"]);
|
||||
expect(".o_web_sign_draw_clear").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveText("Draw");
|
||||
|
||||
await contains(".o_web_sign_load_button").click();
|
||||
expect(getNameAndSignatureButtonNames()).toEqual(["Auto", "Draw", "Load"]);
|
||||
expect(".o_web_sign_load_file").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveText("Load");
|
||||
});
|
||||
|
||||
test("test name_and_signature widget without name", async () => {
|
||||
await mountWithCleanup(NameAndSignature, { props: { signature: {} } });
|
||||
expect(".card-header").toHaveCount(0);
|
||||
expect(".o_web_sign_name_group input").toHaveCount(1);
|
||||
expect(".o_web_sign_name_group input").toHaveValue("");
|
||||
|
||||
await contains(".o_web_sign_name_group input").fill("plop", { instantly: true });
|
||||
expect(getNameAndSignatureButtonNames()).toEqual(["Auto", "Draw", "Load"]);
|
||||
expect(".o_web_sign_auto_select_style").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveText("Auto");
|
||||
expect(".o_web_sign_name_group input").toHaveCount(1);
|
||||
expect(".o_web_sign_name_group input").toHaveValue("plop");
|
||||
|
||||
await contains(".o_web_sign_draw_button").click();
|
||||
expect(".card-header .active").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveText("Draw");
|
||||
});
|
||||
|
||||
test("test name_and_signature widget with noInputName and default name", async function () {
|
||||
const props = {
|
||||
signature: {
|
||||
name: "Don Toliver",
|
||||
},
|
||||
noInputName: true,
|
||||
};
|
||||
await mountWithCleanup(NameAndSignature, { props });
|
||||
expect(getNameAndSignatureButtonNames()).toEqual(["Auto", "Draw", "Load"]);
|
||||
expect(".o_web_sign_auto_select_style").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveText("Auto");
|
||||
});
|
||||
|
||||
test("test name_and_signature widget with noInputName and without name", async function () {
|
||||
const props = {
|
||||
signature: {},
|
||||
noInputName: true,
|
||||
};
|
||||
await mountWithCleanup(NameAndSignature, { props });
|
||||
expect(getNameAndSignatureButtonNames()).toEqual(["Draw", "Load"]);
|
||||
expect(".o_web_sign_draw_clear").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveCount(1);
|
||||
expect(".card-header .active").toHaveText("Draw");
|
||||
});
|
||||
|
||||
test("test name_and_signature widget default signature", async function () {
|
||||
const props = {
|
||||
signature: {
|
||||
name: "Brandon Freeman",
|
||||
signatureImage:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+BCQAHBQICJmhD1AAAAABJRU5ErkJggg==",
|
||||
},
|
||||
mode: "draw",
|
||||
signatureType: "signature",
|
||||
noInputName: true,
|
||||
};
|
||||
const res = await mountWithCleanup(NameAndSignature, { props });
|
||||
expect(res.isSignatureEmpty).toBe(false);
|
||||
expect(res.props.signature.isSignatureEmpty).toBe(false);
|
||||
});
|
||||
|
||||
test("test name_and_signature widget update signmode with onSignatureChange prop", async function () {
|
||||
let currentSignMode = "";
|
||||
const props = {
|
||||
signature: { name: "Test Owner" },
|
||||
onSignatureChange: function (signMode) {
|
||||
if (currentSignMode !== signMode) {
|
||||
currentSignMode = signMode;
|
||||
}
|
||||
},
|
||||
};
|
||||
await mountWithCleanup(NameAndSignature, { props });
|
||||
await contains(".o_web_sign_draw_button").click();
|
||||
expect(currentSignMode).toBe("draw");
|
||||
});
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { after, describe, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
defineModels,
|
||||
getService,
|
||||
makeMockEnv,
|
||||
models,
|
||||
onRpc,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { ERROR_INACCESSIBLE_OR_MISSING } from "@web/core/name_service";
|
||||
import { rpcBus } from "@web/core/network/rpc";
|
||||
|
||||
class Dev extends models.Model {
|
||||
_name = "dev";
|
||||
_rec_name = "display_name";
|
||||
_records = [
|
||||
{ id: 1, display_name: "Julien" },
|
||||
{ id: 2, display_name: "Pierre" },
|
||||
];
|
||||
}
|
||||
|
||||
class PO extends models.Model {
|
||||
_name = "po";
|
||||
_rec_name = "display_name";
|
||||
_records = [{ id: 1, display_name: "Damien" }];
|
||||
}
|
||||
|
||||
defineModels([Dev, PO]);
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
test("single loadDisplayNames", async () => {
|
||||
await makeMockEnv();
|
||||
const displayNames = await getService("name").loadDisplayNames("dev", [1, 2]);
|
||||
expect(displayNames).toEqual({ 1: "Julien", 2: "Pierre" });
|
||||
});
|
||||
|
||||
test("loadDisplayNames is done in silent mode", async () => {
|
||||
await makeMockEnv();
|
||||
|
||||
const onRPCRequest = ({ detail }) => {
|
||||
const silent = detail.settings.silent ? "(silent)" : "";
|
||||
expect.step(`RPC:REQUEST${silent}`);
|
||||
};
|
||||
rpcBus.addEventListener("RPC:REQUEST", onRPCRequest);
|
||||
after(() => rpcBus.removeEventListener("RPC:REQUEST", onRPCRequest));
|
||||
|
||||
await getService("name").loadDisplayNames("dev", [1]);
|
||||
expect.verifySteps(["RPC:REQUEST(silent)"]);
|
||||
});
|
||||
|
||||
test("single loadDisplayNames following addDisplayNames", async () => {
|
||||
await makeMockEnv();
|
||||
onRpc(({ model, method, kwargs }) => {
|
||||
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
|
||||
});
|
||||
|
||||
getService("name").addDisplayNames("dev", { 1: "JUM", 2: "PIPU" });
|
||||
const displayNames = await getService("name").loadDisplayNames("dev", [1, 2]);
|
||||
expect(displayNames).toEqual({ 1: "JUM", 2: "PIPU" });
|
||||
expect.verifySteps([]);
|
||||
});
|
||||
|
||||
test("single loadDisplayNames following addDisplayNames (2)", async () => {
|
||||
await makeMockEnv();
|
||||
onRpc(({ model, method, kwargs }) => {
|
||||
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
|
||||
});
|
||||
|
||||
getService("name").addDisplayNames("dev", { 1: "JUM" });
|
||||
const displayNames = await getService("name").loadDisplayNames("dev", [1, 2]);
|
||||
expect(displayNames).toEqual({ 1: "JUM", 2: "Pierre" });
|
||||
expect.verifySteps(["dev:web_search_read:2"]);
|
||||
});
|
||||
|
||||
test("loadDisplayNames in batch", async () => {
|
||||
await makeMockEnv();
|
||||
onRpc(({ model, method, kwargs }) => {
|
||||
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
|
||||
});
|
||||
|
||||
const loadPromise1 = getService("name").loadDisplayNames("dev", [1]);
|
||||
expect.verifySteps([]);
|
||||
const loadPromise2 = getService("name").loadDisplayNames("dev", [2]);
|
||||
expect.verifySteps([]);
|
||||
|
||||
const [displayNames1, displayNames2] = await Promise.all([loadPromise1, loadPromise2]);
|
||||
expect(displayNames1).toEqual({ 1: "Julien" });
|
||||
expect(displayNames2).toEqual({ 2: "Pierre" });
|
||||
expect.verifySteps(["dev:web_search_read:1,2"]);
|
||||
});
|
||||
|
||||
test("loadDisplayNames on different models", async () => {
|
||||
await makeMockEnv();
|
||||
onRpc(({ model, method, kwargs }) => {
|
||||
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
|
||||
});
|
||||
|
||||
const loadPromise1 = getService("name").loadDisplayNames("dev", [1]);
|
||||
expect.verifySteps([]);
|
||||
const loadPromise2 = getService("name").loadDisplayNames("po", [1]);
|
||||
expect.verifySteps([]);
|
||||
|
||||
const [displayNames1, displayNames2] = await Promise.all([loadPromise1, loadPromise2]);
|
||||
expect(displayNames1).toEqual({ 1: "Julien" });
|
||||
expect(displayNames2).toEqual({ 1: "Damien" });
|
||||
|
||||
expect.verifySteps(["dev:web_search_read:1", "po:web_search_read:1"]);
|
||||
});
|
||||
|
||||
test("invalid id", async () => {
|
||||
await makeMockEnv();
|
||||
try {
|
||||
await getService("name").loadDisplayNames("dev", ["a"]);
|
||||
} catch (error) {
|
||||
expect(error.message).toBe("Invalid ID: a");
|
||||
}
|
||||
});
|
||||
|
||||
test("inaccessible or missing id", async () => {
|
||||
await makeMockEnv();
|
||||
onRpc(({ model, method, kwargs }) => {
|
||||
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
|
||||
});
|
||||
|
||||
const displayNames = await getService("name").loadDisplayNames("dev", [3]);
|
||||
expect(displayNames).toEqual({ 3: ERROR_INACCESSIBLE_OR_MISSING });
|
||||
expect.verifySteps(["dev:web_search_read:3"]);
|
||||
});
|
||||
|
||||
test("batch + inaccessible/missing", async () => {
|
||||
await makeMockEnv();
|
||||
onRpc(({ model, method, kwargs }) => {
|
||||
expect.step(`${model}:${method}:${kwargs.domain[0][2]}`);
|
||||
});
|
||||
|
||||
const loadPromise1 = getService("name").loadDisplayNames("dev", [1, 3]);
|
||||
expect.verifySteps([]);
|
||||
const loadPromise2 = getService("name").loadDisplayNames("dev", [2, 4]);
|
||||
expect.verifySteps([]);
|
||||
|
||||
const [displayNames1, displayNames2] = await Promise.all([loadPromise1, loadPromise2]);
|
||||
expect(displayNames1).toEqual({ 1: "Julien", 3: ERROR_INACCESSIBLE_OR_MISSING });
|
||||
expect(displayNames2).toEqual({ 2: "Pierre", 4: ERROR_INACCESSIBLE_OR_MISSING });
|
||||
expect.verifySteps(["dev:web_search_read:1,3,2,4"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import { Component, xml } from "@odoo/owl";
|
||||
import { useNavigation } from "@web/core/navigation/navigation";
|
||||
import { useAutofocus } from "@web/core/utils/hooks";
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { hover, press } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
class BasicHookParent extends Component {
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<button class="outside" t-ref="outsideRef">outside target</button>
|
||||
<div class="container" t-ref="containerRef">
|
||||
<button class="o-navigable one" t-on-click="() => this.onClick(1)">target one</button>
|
||||
<div class="o-navigable two" tabindex="0" t-on-click="() => this.onClick(2)">target two</div>
|
||||
<input class="o-navigable three" t-on-click="() => this.onClick(3)"/><br/>
|
||||
<button class="no-nav-class">skipped</button><br/>
|
||||
<a class="o-navigable four" tabindex="0" t-on-click="() => this.onClick(4)">target four</a>
|
||||
<div class="o-navigable five">
|
||||
<button t-on-click="() => this.onClick(5)">target five</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
useAutofocus({ refName: "outsideRef" });
|
||||
this.navigation = useNavigation("containerRef", this.navOptions);
|
||||
}
|
||||
|
||||
navOptions = {};
|
||||
onClick(id) {}
|
||||
}
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("default navigation", async () => {
|
||||
async function navigate(hotkey, focused) {
|
||||
await press(hotkey);
|
||||
await animationFrame();
|
||||
|
||||
expect(focused).toBeFocused();
|
||||
expect(focused).toHaveClass("focus");
|
||||
}
|
||||
|
||||
class Parent extends BasicHookParent {
|
||||
onClick(id) {
|
||||
expect.step(id);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(".one").toBeFocused();
|
||||
|
||||
await navigate("arrowdown", ".two");
|
||||
await navigate("arrowdown", ".three");
|
||||
await navigate("arrowdown", ".four");
|
||||
await navigate("arrowdown", ".five button");
|
||||
await navigate("arrowdown", ".one");
|
||||
|
||||
await navigate("arrowup", ".five button");
|
||||
await navigate("arrowup", ".four");
|
||||
|
||||
await navigate("end", ".five button");
|
||||
await navigate("home", ".one");
|
||||
|
||||
await navigate("tab", ".two");
|
||||
await navigate("shift+tab", ".one");
|
||||
|
||||
await navigate("arrowleft", ".one");
|
||||
await navigate("arrowright", ".one");
|
||||
await navigate("space", ".one");
|
||||
await navigate("escape", ".one");
|
||||
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
expect.verifySteps([1]);
|
||||
|
||||
await navigate("arrowdown", ".two");
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
expect.verifySteps([2]);
|
||||
});
|
||||
|
||||
test("hotkey override options", async () => {
|
||||
class Parent extends BasicHookParent {
|
||||
navOptions = {
|
||||
hotkeys: {
|
||||
arrowleft: (index, items) => {
|
||||
expect.step(index);
|
||||
items[(index + 2) % items.length].focus();
|
||||
},
|
||||
escape: (index, items) => {
|
||||
expect.step("escape");
|
||||
items[0].focus();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
onClick(id) {
|
||||
expect.step(id);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(".one").toBeFocused();
|
||||
|
||||
await press("arrowleft");
|
||||
await animationFrame();
|
||||
expect(".three").toBeFocused();
|
||||
expect.verifySteps([0]);
|
||||
|
||||
await press("escape");
|
||||
await animationFrame();
|
||||
expect(".one").toBeFocused();
|
||||
expect.verifySteps(["escape"]);
|
||||
});
|
||||
|
||||
test("navigation with virtual focus", async () => {
|
||||
async function navigate(hotkey, expected) {
|
||||
await press(hotkey);
|
||||
await animationFrame();
|
||||
// Focus is kept on button outside container
|
||||
expect(".outside").toBeFocused();
|
||||
// Virtually focused element has "focus" class
|
||||
expect(expected).toHaveClass("focus");
|
||||
}
|
||||
|
||||
class Parent extends BasicHookParent {
|
||||
navOptions = {
|
||||
virtualFocus: true,
|
||||
};
|
||||
|
||||
onClick(id) {
|
||||
expect.step(id);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(".one").toHaveClass("focus");
|
||||
await navigate("arrowdown", ".two");
|
||||
await navigate("arrowdown", ".three");
|
||||
await navigate("arrowdown", ".four");
|
||||
await navigate("arrowdown", ".five button");
|
||||
await navigate("arrowdown", ".one");
|
||||
|
||||
await navigate("arrowup", ".five button");
|
||||
await navigate("arrowup", ".four");
|
||||
|
||||
await navigate("end", ".five button");
|
||||
await navigate("home", ".one");
|
||||
|
||||
await navigate("tab", ".two");
|
||||
await navigate("shift+tab", ".one");
|
||||
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
expect.verifySteps([1]);
|
||||
|
||||
await navigate("arrowdown", ".two");
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
expect.verifySteps([2]);
|
||||
});
|
||||
|
||||
test("hovering an item makes it active but doesn't focus", async () => {
|
||||
await mountWithCleanup(BasicHookParent);
|
||||
|
||||
await press("arrowdown");
|
||||
|
||||
expect(".two").toBeFocused();
|
||||
expect(".two").toHaveClass("focus");
|
||||
|
||||
hover(".three");
|
||||
await animationFrame();
|
||||
|
||||
expect(".two").toBeFocused();
|
||||
expect(".two").not.toHaveClass("focus");
|
||||
|
||||
expect(".three").not.toBeFocused();
|
||||
expect(".three").toHaveClass("focus");
|
||||
|
||||
press("arrowdown");
|
||||
await animationFrame();
|
||||
expect(".four").toBeFocused();
|
||||
expect(".four").toHaveClass("focus");
|
||||
});
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { after, describe, expect, test } from "@odoo/hoot";
|
||||
import { Deferred, mockFetch } from "@odoo/hoot-mock";
|
||||
import { patchTranslations } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { download } from "@web/core/network/download";
|
||||
import { ConnectionLostError, RPCError } from "@web/core/network/rpc";
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
test("handles connection error when behind a server", async () => {
|
||||
mockFetch(() => new Response("", { status: 502 }));
|
||||
|
||||
const error = new ConnectionLostError("/some_url");
|
||||
await expect(download({ data: {}, url: "/some_url" })).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test("handles connection error when network unavailable", async () => {
|
||||
mockFetch(() => Promise.reject());
|
||||
|
||||
const error = new ConnectionLostError("/some_url");
|
||||
await expect(download({ data: {}, url: "/some_url" })).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test("handles business error from server", async () => {
|
||||
const serverError = {
|
||||
code: 200,
|
||||
data: {
|
||||
name: "odoo.exceptions.RedirectWarning",
|
||||
arguments: ["Business Error Message", "someArg"],
|
||||
message: "Business Error Message",
|
||||
},
|
||||
message: "Odoo Server Error",
|
||||
};
|
||||
|
||||
mockFetch(() => new Blob([JSON.stringify(serverError)], { type: "text/html" }));
|
||||
|
||||
let error = null;
|
||||
try {
|
||||
await download({
|
||||
data: {},
|
||||
url: "/some_url",
|
||||
});
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error).toBeInstanceOf(RPCError);
|
||||
expect(error.data).toEqual(serverError.data);
|
||||
});
|
||||
|
||||
test("handles arbitrary error", async () => {
|
||||
const serverError = /* xml */ `<html><body><div>HTML error message</div></body></html>`;
|
||||
|
||||
mockFetch(() => new Blob([JSON.stringify(serverError)], { type: "text/html" }));
|
||||
|
||||
let error = null;
|
||||
try {
|
||||
await download({
|
||||
data: {},
|
||||
url: "/some_url",
|
||||
});
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(RPCError);
|
||||
expect(error.message).toBe("Arbitrary Uncaught Python Exception");
|
||||
expect(error.data.debug.trim()).toBe("200\nHTML error message");
|
||||
});
|
||||
|
||||
test("handles success download", async () => {
|
||||
patchTranslations();
|
||||
// This test relies on a implementation detail of the lowest layer of download
|
||||
// That is, a link will be created with the download attribute
|
||||
|
||||
mockFetch((_, { body }) => {
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
expect(body.get("someKey")).toBe("someValue");
|
||||
expect(body.has("token")).toBe(true);
|
||||
expect(body.has("csrf_token")).toBe(true);
|
||||
expect.step("fetching file");
|
||||
|
||||
return new Blob(["some plain text file"], { type: "text/plain" });
|
||||
});
|
||||
|
||||
const deferred = new Deferred();
|
||||
|
||||
// This part asserts the implementation detail in question
|
||||
const downloadOnClick = (ev) => {
|
||||
const target = ev.target;
|
||||
if (target.tagName === "A" && "download" in target.attributes) {
|
||||
ev.preventDefault();
|
||||
|
||||
expect(target.href).toMatch(/^blob:/);
|
||||
expect.step("file downloaded");
|
||||
document.removeEventListener("click", downloadOnClick);
|
||||
deferred.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("click", downloadOnClick);
|
||||
after(() => document.removeEventListener("click", downloadOnClick));
|
||||
|
||||
expect("a[download]").toHaveCount(0); // link will be added by download
|
||||
download({ data: { someKey: "someValue" }, url: "/some_url" });
|
||||
await deferred;
|
||||
expect.verifySteps(["fetching file", "file downloaded"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { mockFetch } from "@odoo/hoot-mock";
|
||||
|
||||
import { get, post } from "@web/core/network/http_service";
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
test("method is correctly set", async () => {
|
||||
mockFetch((_, { method }) => expect.step(method));
|
||||
|
||||
await get("/call_get");
|
||||
expect.verifySteps(["GET"]);
|
||||
|
||||
await post("/call_post");
|
||||
expect.verifySteps(["POST"]);
|
||||
});
|
||||
|
||||
test("check status 502", async () => {
|
||||
mockFetch(() => new Response("{}", { status: 502 }));
|
||||
|
||||
await expect(get("/custom_route")).rejects.toThrow(/Failed to fetch/);
|
||||
});
|
||||
|
||||
test("FormData is built by post", async () => {
|
||||
mockFetch((_, { body }) => {
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
expect(body.get("s")).toBe("1");
|
||||
expect(body.get("a")).toBe("1");
|
||||
expect(body.getAll("a")).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
await post("call_post", { s: 1, a: [1, 2, 3] });
|
||||
});
|
||||
|
||||
test("FormData is given to post", async () => {
|
||||
const formData = new FormData();
|
||||
mockFetch((_, { body }) => expect(body).toBe(formData));
|
||||
|
||||
await post("/call_post", formData);
|
||||
});
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import { after, describe, expect, test } from "@odoo/hoot";
|
||||
import { on } from "@odoo/hoot-dom";
|
||||
import { mockFetch } from "@odoo/hoot-mock";
|
||||
|
||||
import {
|
||||
ConnectionAbortedError,
|
||||
ConnectionLostError,
|
||||
RPCError,
|
||||
rpc,
|
||||
rpcBus,
|
||||
} from "@web/core/network/rpc";
|
||||
|
||||
const onRpcRequest = (listener) => after(on(rpcBus, "RPC:REQUEST", listener));
|
||||
const onRpcResponse = (listener) => after(on(rpcBus, "RPC:RESPONSE", listener));
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
test("can perform a simple rpc", async () => {
|
||||
mockFetch((_, { body }) => {
|
||||
const bodyObject = JSON.parse(body);
|
||||
expect(bodyObject.jsonrpc).toBe("2.0");
|
||||
expect(bodyObject.method).toBe("call");
|
||||
expect(bodyObject.id).toBeOfType("integer");
|
||||
return { result: { action_id: 123 } };
|
||||
});
|
||||
|
||||
expect(await rpc("/test/")).toEqual({ action_id: 123 });
|
||||
});
|
||||
|
||||
test("trigger an error when response has 'error' key", async () => {
|
||||
mockFetch(() => ({
|
||||
error: {
|
||||
message: "message",
|
||||
code: 12,
|
||||
data: {
|
||||
debug: "data_debug",
|
||||
message: "data_message",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const error = new RPCError("message");
|
||||
await expect(rpc("/test/")).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test("rpc with simple routes", async () => {
|
||||
mockFetch((route, { body }) => ({
|
||||
result: { route, params: JSON.parse(body).params },
|
||||
}));
|
||||
|
||||
expect(await rpc("/my/route")).toEqual({ route: "/my/route", params: {} });
|
||||
expect(await rpc("/my/route", { hey: "there", model: "test" })).toEqual({
|
||||
route: "/my/route",
|
||||
params: { hey: "there", model: "test" },
|
||||
});
|
||||
});
|
||||
|
||||
test("check trigger RPC:REQUEST and RPC:RESPONSE for a simple rpc", async () => {
|
||||
mockFetch(() => ({ result: {} }));
|
||||
|
||||
const rpcIdsRequest = [];
|
||||
const rpcIdsResponse = [];
|
||||
|
||||
onRpcRequest(({ detail }) => {
|
||||
rpcIdsRequest.push(detail.data.id);
|
||||
const silent = detail.settings.silent ? "(silent)" : "";
|
||||
expect.step(`RPC:REQUEST${silent}`);
|
||||
});
|
||||
onRpcResponse(({ detail }) => {
|
||||
rpcIdsResponse.push(detail.data.id);
|
||||
const silent = detail.settings.silent ? "(silent)" : "";
|
||||
const success = "result" in detail ? "(ok)" : "";
|
||||
const fail = "error" in detail ? "(ko)" : "";
|
||||
expect.step(`RPC:RESPONSE${silent}${success}${fail}`);
|
||||
});
|
||||
|
||||
await rpc("/test/");
|
||||
expect(rpcIdsRequest.toString()).toBe(rpcIdsResponse.toString());
|
||||
expect.verifySteps(["RPC:REQUEST", "RPC:RESPONSE(ok)"]);
|
||||
|
||||
await rpc("/test/", {}, { silent: true });
|
||||
expect(rpcIdsRequest.toString()).toBe(rpcIdsResponse.toString());
|
||||
expect.verifySteps(["RPC:REQUEST(silent)", "RPC:RESPONSE(silent)(ok)"]);
|
||||
});
|
||||
|
||||
test("check trigger RPC:REQUEST and RPC:RESPONSE for a rpc with an error", async () => {
|
||||
mockFetch(() => ({
|
||||
error: {
|
||||
message: "message",
|
||||
code: 12,
|
||||
data: {
|
||||
debug: "data_debug",
|
||||
message: "data_message",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const rpcIdsRequest = [];
|
||||
const rpcIdsResponse = [];
|
||||
|
||||
onRpcRequest(({ detail }) => {
|
||||
rpcIdsRequest.push(detail.data.id);
|
||||
const silent = detail.settings.silent ? "(silent)" : "";
|
||||
expect.step(`RPC:REQUEST${silent}`);
|
||||
});
|
||||
onRpcResponse(({ detail }) => {
|
||||
rpcIdsResponse.push(detail.data.id);
|
||||
const silent = detail.settings.silent ? "(silent)" : "";
|
||||
const success = "result" in detail ? "(ok)" : "";
|
||||
const fail = "error" in detail ? "(ko)" : "";
|
||||
expect.step(`RPC:RESPONSE${silent}${success}${fail}`);
|
||||
});
|
||||
|
||||
const error = new RPCError("message");
|
||||
await expect(rpc("/test/")).rejects.toThrow(error);
|
||||
expect.verifySteps(["RPC:REQUEST", "RPC:RESPONSE(ko)"]);
|
||||
});
|
||||
|
||||
test("check connection aborted", async () => {
|
||||
mockFetch(() => new Promise(() => {}));
|
||||
onRpcRequest(() => expect.step("RPC:REQUEST"));
|
||||
onRpcResponse(() => expect.step("RPC:RESPONSE"));
|
||||
|
||||
const connection = rpc();
|
||||
connection.abort();
|
||||
const error = new ConnectionAbortedError();
|
||||
await expect(connection).rejects.toThrow(error);
|
||||
expect.verifySteps(["RPC:REQUEST", "RPC:RESPONSE"]);
|
||||
});
|
||||
|
||||
test("trigger a ConnectionLostError when response isn't json parsable", async () => {
|
||||
mockFetch(() => new Response("<h...", { status: 500 }));
|
||||
|
||||
const error = new ConnectionLostError("/test/");
|
||||
await expect(rpc("/test/")).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test("rpc can send additional headers", async () => {
|
||||
mockFetch((url, settings) => {
|
||||
expect(settings.headers).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
Hello: "World",
|
||||
});
|
||||
return { result: true };
|
||||
});
|
||||
await rpc("/test/", null, { headers: { Hello: "World" } });
|
||||
});
|
||||
356
odoo-bringout-oca-ocb-web/web/static/tests/core/notebook.test.js
Normal file
356
odoo-bringout-oca-ocb-web/web/static/tests/core/notebook.test.js
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { click, queryFirst } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { Notebook } from "@web/core/notebook/notebook";
|
||||
|
||||
test("not rendered if empty slots", async () => {
|
||||
await mountWithCleanup(Notebook);
|
||||
expect("div.o_notebook").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("notebook with multiple pages given as slots", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<Notebook>
|
||||
<t t-set-slot="page_about" title="'About'" isVisible="true">
|
||||
<h3>About the bird</h3>
|
||||
<p>Owls are birds from the order Strigiformes which includes over
|
||||
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
|
||||
</t>
|
||||
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
|
||||
<h3>Their favorite activity: hunting</h3>
|
||||
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
|
||||
</t>
|
||||
<t t-set-slot="page_secret" title="'Secret about OWLs'" isVisible="false">
|
||||
<p>TODO find a great secret about OWLs.</p>
|
||||
</t>
|
||||
</Notebook>`;
|
||||
static components = { Notebook };
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect("div.o_notebook").toHaveCount(1);
|
||||
expect(".o_notebook").toHaveClass("horizontal", {
|
||||
message: "default orientation is set as horizontal",
|
||||
});
|
||||
expect(".nav").toHaveClass("flex-row", {
|
||||
message: "navigation container uses the right class to display as horizontal tabs",
|
||||
});
|
||||
expect(".o_notebook_headers a.nav-link").toHaveCount(2, {
|
||||
message: "navigation link is present for each visible page",
|
||||
});
|
||||
expect(".o_notebook_headers .nav-item:first-child a").toHaveClass("active", {
|
||||
message: "first page is selected by default",
|
||||
});
|
||||
expect(".active h3").toHaveText("About the bird", {
|
||||
message: "first page content is displayed by the notebook",
|
||||
});
|
||||
|
||||
await click(".o_notebook_headers .nav-item:nth-child(2) a");
|
||||
await animationFrame();
|
||||
expect(".o_notebook_headers .nav-item:nth-child(2) a").toHaveClass("active", {
|
||||
message: "second page is now selected",
|
||||
});
|
||||
expect(".active h3").toHaveText("Their favorite activity: hunting", {
|
||||
message: "second page content is displayed by the notebook",
|
||||
});
|
||||
});
|
||||
|
||||
test("notebook with defaultPage props", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<Notebook defaultPage="'page_hunting'">
|
||||
<t t-set-slot="page_about" title="'About'" isVisible="true">
|
||||
<h3>About the bird</h3>
|
||||
<p>Owls are birds from the order Strigiformes which includes over
|
||||
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
|
||||
</t>
|
||||
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
|
||||
<h3>Their favorite activity: hunting</h3>
|
||||
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
|
||||
</t>
|
||||
<t t-set-slot="page_secret" title="'Secret about OWLs'" isVisible="false">
|
||||
<p>TODO find a great secret about OWLs.</p>
|
||||
</t>
|
||||
</Notebook>`;
|
||||
static components = { Notebook };
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect("div.o_notebook").toHaveCount(1);
|
||||
expect(".o_notebook_headers .nav-item:nth-child(2) a").toHaveClass("active", {
|
||||
message: "second page is selected by default",
|
||||
});
|
||||
expect(".active h3").toHaveText("Their favorite activity: hunting", {
|
||||
message: "second page content is displayed by the notebook",
|
||||
});
|
||||
});
|
||||
|
||||
test("notebook with defaultPage set on invisible page", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<Notebook defaultPage="'page_secret'">
|
||||
<t t-set-slot="page_about" title="'About'" isVisible="true">
|
||||
<h3>About the bird</h3>
|
||||
<p>Owls are birds from the order Strigiformes which includes over
|
||||
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
|
||||
</t>
|
||||
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
|
||||
<h3>Their favorite activity: hunting</h3>
|
||||
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
|
||||
</t>
|
||||
<t t-set-slot="page_secret" title="'Secret about OWLs'" isVisible="false">
|
||||
<h3>Oooops</h3>
|
||||
<p>TODO find a great secret to reveal about OWLs.</p>
|
||||
</t>
|
||||
</Notebook>`;
|
||||
static components = { Notebook };
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_notebook_headers .nav-item a.active").toHaveText("About", {
|
||||
message: "The first page is selected",
|
||||
});
|
||||
});
|
||||
|
||||
test("notebook set vertically", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<Notebook orientation="'vertical'">
|
||||
<t t-set-slot="page_about" title="'About'" isVisible="true">
|
||||
<h3>About the bird</h3>
|
||||
<p>Owls are birds from the order Strigiformes which includes over
|
||||
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
|
||||
</t>
|
||||
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
|
||||
<h3>Their favorite activity: hunting</h3>
|
||||
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
|
||||
</t>
|
||||
</Notebook>`;
|
||||
static components = { Notebook };
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect("div.o_notebook").toHaveCount(1);
|
||||
expect(".o_notebook").toHaveClass("vertical", {
|
||||
message: "orientation is set as vertical",
|
||||
});
|
||||
expect(".nav").toHaveClass("flex-column", {
|
||||
message: "navigation container uses the right class to display as vertical buttons",
|
||||
});
|
||||
});
|
||||
|
||||
test("notebook pages rendered by a template component", async () => {
|
||||
class NotebookPageRenderer extends Component {
|
||||
static template = xml`
|
||||
<h3 t-esc="props.heading"></h3>
|
||||
<p t-esc="props.text" />
|
||||
`;
|
||||
static props = {
|
||||
heading: String,
|
||||
text: String,
|
||||
};
|
||||
}
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`<Notebook defaultPage="'page_three'" pages="pages">
|
||||
<t t-set-slot="page_one" title="'Page 1'" isVisible="true">
|
||||
<h3>Page 1</h3>
|
||||
<p>First page set directly as a slot</p>
|
||||
</t>
|
||||
<t t-set-slot="page_four" title="'Page 4'" isVisible="true">
|
||||
<h3>Page 4</h3>
|
||||
</t>
|
||||
</Notebook>`;
|
||||
static components = { Notebook };
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.pages = [
|
||||
{
|
||||
Component: NotebookPageRenderer,
|
||||
index: 1,
|
||||
title: "Page 2",
|
||||
props: {
|
||||
heading: "Page 2",
|
||||
text: "Second page rendered by a template component",
|
||||
},
|
||||
},
|
||||
{
|
||||
Component: NotebookPageRenderer,
|
||||
id: "page_three", // required to be set as default page
|
||||
index: 2,
|
||||
title: "Page 3",
|
||||
props: {
|
||||
heading: "Page 3",
|
||||
text: "Third page rendered by a template component",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect("div.o_notebook").toHaveCount(1);
|
||||
expect(".o_notebook_headers .nav-item:nth-child(3) a").toHaveClass("active", {
|
||||
message: "third page is selected by default",
|
||||
});
|
||||
|
||||
await click(".o_notebook_headers .nav-item:nth-child(2) a");
|
||||
await animationFrame();
|
||||
expect(".o_notebook_content p").toHaveText("Second page rendered by a template component", {
|
||||
message: "displayed content corresponds to the current page",
|
||||
});
|
||||
});
|
||||
|
||||
test("each page is different", async () => {
|
||||
class Page extends Component {
|
||||
static template = xml`<h3>Coucou</h3>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`<Notebook pages="pages"/>`;
|
||||
static components = { Notebook };
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.pages = [
|
||||
{
|
||||
Component: Page,
|
||||
index: 1,
|
||||
title: "Page 1",
|
||||
},
|
||||
{
|
||||
Component: Page,
|
||||
index: 2,
|
||||
title: "Page 2",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
const firstPage = queryFirst("h3");
|
||||
expect(firstPage).toBeInstanceOf(HTMLElement);
|
||||
|
||||
await click(".o_notebook_headers .nav-item:nth-child(2) a");
|
||||
await animationFrame();
|
||||
const secondPage = queryFirst("h3");
|
||||
expect(secondPage).toBeInstanceOf(HTMLElement);
|
||||
expect(firstPage).not.toBe(secondPage);
|
||||
});
|
||||
|
||||
test("defaultPage recomputed when isVisible is dynamic", async () => {
|
||||
let defaultPageVisible = false;
|
||||
class Parent extends Component {
|
||||
static components = { Notebook };
|
||||
static template = xml`
|
||||
<Notebook defaultPage="'3'">
|
||||
<t t-set-slot="1" title="'page1'" isVisible="true">
|
||||
<div class="page1" />
|
||||
</t>
|
||||
<t t-set-slot="2" title="'page2'" isVisible="true">
|
||||
<div class="page2" />
|
||||
</t>
|
||||
<t t-set-slot="3" title="'page3'" isVisible="defaultPageVisible">
|
||||
<div class="page3" />
|
||||
</t>
|
||||
</Notebook>`;
|
||||
static props = ["*"];
|
||||
get defaultPageVisible() {
|
||||
return defaultPageVisible;
|
||||
}
|
||||
}
|
||||
|
||||
const parent = await mountWithCleanup(Parent);
|
||||
expect(".page1").toHaveCount(1);
|
||||
expect(".nav-link.active").toHaveText("page1");
|
||||
defaultPageVisible = true;
|
||||
parent.render(true);
|
||||
|
||||
await animationFrame();
|
||||
expect(".page3").toHaveCount(1);
|
||||
expect(".nav-link.active").toHaveText("page3");
|
||||
|
||||
await click(".o_notebook_headers .nav-item:nth-child(2) a");
|
||||
await animationFrame();
|
||||
expect(".page2").toHaveCount(1);
|
||||
expect(".nav-link.active").toHaveText("page2");
|
||||
|
||||
parent.render(true);
|
||||
await animationFrame();
|
||||
expect(".page2").toHaveCount(1);
|
||||
expect(".nav-link.active").toHaveText("page2");
|
||||
});
|
||||
|
||||
test("disabled pages are greyed out and can't be toggled", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { Notebook };
|
||||
static template = xml`
|
||||
<Notebook defaultPage="'1'">
|
||||
<t t-set-slot="1" title="'page1'" isVisible="true">
|
||||
<div class="page1" />
|
||||
</t>
|
||||
<t t-set-slot="2" title="'page2'" isVisible="true" isDisabled="true">
|
||||
<div class="page2" />
|
||||
</t>
|
||||
<t t-set-slot="3" title="'page3'" isVisible="true">
|
||||
<div class="page3" />
|
||||
</t>
|
||||
</Notebook>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".page1").toHaveCount(1);
|
||||
expect(".nav-item:nth-child(2)").toHaveClass("disabled", {
|
||||
message: "tab of the disabled page is greyed out",
|
||||
});
|
||||
|
||||
await click(".nav-item:nth-child(2) .nav-link");
|
||||
await animationFrame();
|
||||
expect(".page1").toHaveCount(1, {
|
||||
message: "the same page is still displayed",
|
||||
});
|
||||
|
||||
await click(".nav-item:nth-child(3) .nav-link");
|
||||
await animationFrame();
|
||||
expect(".page3").toHaveCount(1, {
|
||||
message: "the third page is now displayed",
|
||||
});
|
||||
});
|
||||
|
||||
test("icons can be given for each page tab", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { Notebook };
|
||||
static template = xml`
|
||||
<Notebook defaultPage="'1'" icons="icons">
|
||||
<t t-set-slot="1" title="'page1'" isVisible="true">
|
||||
<div class="page1" />
|
||||
</t>
|
||||
<t t-set-slot="2" title="'page2'" isVisible="true">
|
||||
<div class="page2" />
|
||||
</t>
|
||||
<t t-set-slot="3" title="'page3'" isVisible="true">
|
||||
<div class="page3" />
|
||||
</t>
|
||||
</Notebook>`;
|
||||
static props = ["*"];
|
||||
get icons() {
|
||||
return {
|
||||
1: "fa-trash",
|
||||
3: "fa-pencil",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".nav-item:nth-child(1) i").toHaveClass("fa-trash");
|
||||
expect(".nav-item:nth-child(1)").toHaveText("page1");
|
||||
expect(".nav-item:nth-child(2) i").toHaveCount(0);
|
||||
expect(".nav-item:nth-child(2)").toHaveText("page2");
|
||||
expect(".nav-item:nth-child(3) i").toHaveClass("fa-pencil");
|
||||
expect(".nav-item:nth-child(3)").toHaveText("page3");
|
||||
});
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { click, hover, leave } from "@odoo/hoot-dom";
|
||||
import { advanceTime, animationFrame, runAllTimers } from "@odoo/hoot-mock";
|
||||
import { markup } from "@odoo/owl";
|
||||
import { getService, makeMockEnv, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
test("can display a basic notification", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("I'm a basic notification");
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
expect(".o_notification_content").toHaveText("I'm a basic notification");
|
||||
expect(".o_notification_bar").toHaveClass("bg-warning");
|
||||
});
|
||||
|
||||
test("can display a notification with a className", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("I'm a basic notification", { className: "abc" });
|
||||
await animationFrame();
|
||||
expect(".o_notification.abc").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("title and message are escaped by default", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("<i>Some message</i>", { title: "<b>Some title</b>" });
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
expect(".o_notification_title").toHaveText("<b>Some title</b>");
|
||||
expect(".o_notification_content").toHaveText("<i>Some message</i>");
|
||||
});
|
||||
|
||||
test("can display a notification with markup content", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add(markup("<b>I'm a <i>markup</i> notification</b>"));
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
expect(".o_notification_content").toHaveInnerHTML("<b>I'm a <i>markup</i> notification</b>");
|
||||
});
|
||||
|
||||
test("can display a notification of type danger", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("I'm a danger notification", { type: "danger" });
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
expect(".o_notification_content").toHaveText("I'm a danger notification");
|
||||
expect(".o_notification_bar").toHaveClass("bg-danger");
|
||||
});
|
||||
|
||||
test("can display a danger notification with a title", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("I'm a danger notification", {
|
||||
title: "Some title",
|
||||
type: "danger",
|
||||
});
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
expect(".o_notification_title").toHaveText("Some title");
|
||||
expect(".o_notification_content").toHaveText("I'm a danger notification");
|
||||
expect(".o_notification_bar").toHaveClass("bg-danger");
|
||||
});
|
||||
|
||||
test("can display a notification with a button", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("I'm a notification with button", {
|
||||
buttons: [
|
||||
{
|
||||
name: "I'm a button",
|
||||
primary: true,
|
||||
onClick: () => {
|
||||
expect.step("Button clicked");
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
expect(".o_notification_buttons").toHaveText("I'm a button");
|
||||
await click(".o_notification .btn-primary");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["Button clicked"]);
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("can display a notification with a callback when closed", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("I'm a sticky notification", {
|
||||
sticky: true,
|
||||
onClose: () => {
|
||||
expect.step("Notification closed");
|
||||
},
|
||||
});
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
await click(".o_notification .o_notification_close");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["Notification closed"]);
|
||||
expect(".o_notification").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("notifications aren't sticky by default", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("I'm a notification");
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
// Wait for the notification to close
|
||||
await runAllTimers();
|
||||
expect(".o_notification").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("can display a sticky notification", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("I'm a sticky notification", { sticky: true });
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
await advanceTime(5000);
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("can close sticky notification", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
const closeNotif = getService("notification").add("I'm a sticky notification", {
|
||||
sticky: true,
|
||||
});
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
|
||||
// close programmatically
|
||||
closeNotif();
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(0);
|
||||
|
||||
getService("notification").add("I'm a sticky notification", { sticky: true });
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
|
||||
// close by clicking on the close icon
|
||||
await click(".o_notification .o_notification_close");
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(0);
|
||||
});
|
||||
|
||||
// The timeout have to be done by the one that uses the notification service
|
||||
test.skip("can close sticky notification with wait", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
const closeNotif = getService("notification").add("I'm a sticky notification", {
|
||||
sticky: true,
|
||||
});
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
|
||||
// close programmatically
|
||||
getService("notification").close(closeNotif, 3000);
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
|
||||
// simulate end of timeout
|
||||
await advanceTime(3000);
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("can close a non-sticky notification", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
const closeNotif = getService("notification").add("I'm a sticky notification");
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
|
||||
// close the notification
|
||||
closeNotif();
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(0);
|
||||
|
||||
// simulate end of timeout, which should try to close the notification as well
|
||||
await runAllTimers();
|
||||
expect(".o_notification").toHaveCount(0);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("can refresh the duration of a non-sticky notification", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("I'm a first non-sticky notification");
|
||||
getService("notification").add("I'm a second non-sticky notification");
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(2);
|
||||
|
||||
await advanceTime(3000);
|
||||
await hover(".o_notification:first-child");
|
||||
await advanceTime(5000);
|
||||
// Both notifications should be visible as long as mouse is over one of them
|
||||
expect(".o_notification").toHaveCount(2);
|
||||
await leave();
|
||||
await advanceTime(3000);
|
||||
// Both notifications should be refreshed in duration (4000 ms)
|
||||
expect(".o_notification").toHaveCount(2);
|
||||
await advanceTime(2000);
|
||||
expect(".o_notification").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("close a non-sticky notification while another one remains", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
const closeNotif1 = getService("notification").add("I'm a non-sticky notification");
|
||||
const closeNotif2 = getService("notification").add("I'm a sticky notification", {
|
||||
sticky: true,
|
||||
});
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(2);
|
||||
|
||||
// close the non sticky notification
|
||||
closeNotif1();
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
|
||||
// simulate end of timeout, which should try to close notification 1 as well
|
||||
await runAllTimers();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
|
||||
// close the non sticky notification
|
||||
closeNotif2();
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("notification coming when NotificationManager not mounted yet", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("I'm a non-sticky notification");
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("notification autocloses after a specified delay", async () => {
|
||||
await makeMockEnv();
|
||||
const { Component: NotificationContainer, props } = registry
|
||||
.category("main_components")
|
||||
.get("NotificationContainer");
|
||||
|
||||
await mountWithCleanup(NotificationContainer, { props, noMainContainer: true });
|
||||
getService("notification").add("custom autoclose delay notification", {
|
||||
autocloseDelay: 1000,
|
||||
});
|
||||
|
||||
await advanceTime(500);
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
|
||||
await advanceTime(500);
|
||||
await animationFrame();
|
||||
expect(".o_notification").toHaveCount(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,511 @@
|
|||
import { after, describe, expect, test } from "@odoo/hoot";
|
||||
import { on } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { getService, makeMockEnv, mountWithCleanup, onRpc } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { rpcBus } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
test("add user context to a simple read request", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [[3], ["id", "descr"]],
|
||||
kwargs: {
|
||||
context: {
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
},
|
||||
method: "read",
|
||||
model: "res.partner",
|
||||
});
|
||||
return false; // Don't want to call the actual read method
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.read("res.partner", [3], ["id", "descr"]);
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/res.partner/read"]);
|
||||
});
|
||||
|
||||
test("context is combined with user context in read request", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [[3], ["id", "descr"]],
|
||||
kwargs: {
|
||||
context: {
|
||||
allowed_company_ids: [1],
|
||||
earth: "isfucked",
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
},
|
||||
method: "read",
|
||||
model: "res.partner",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.read("res.partner", [3], ["id", "descr"], {
|
||||
context: {
|
||||
earth: "isfucked",
|
||||
},
|
||||
});
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/res.partner/read"]);
|
||||
});
|
||||
|
||||
test("basic method call of model", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [],
|
||||
kwargs: {
|
||||
context: {
|
||||
a: 1,
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
},
|
||||
method: "test",
|
||||
model: "res.partner",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.call("res.partner", "test", [], { context: { a: 1 } });
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/res.partner/test"]);
|
||||
});
|
||||
|
||||
test("create method: one record", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [[{ color: "red" }]],
|
||||
kwargs: {
|
||||
context: {
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
},
|
||||
method: "create",
|
||||
model: "res.partner",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.create("res.partner", [{ color: "red" }]);
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/res.partner/create"]);
|
||||
});
|
||||
|
||||
test("create method: several records", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [[{ color: "red" }, { color: "green" }]],
|
||||
kwargs: {
|
||||
context: {
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
},
|
||||
method: "create",
|
||||
model: "res.partner",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.create("res.partner", [{ color: "red" }, { color: "green" }]);
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/res.partner/create"]);
|
||||
});
|
||||
|
||||
test("read method", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [
|
||||
[2, 5],
|
||||
["name", "amount"],
|
||||
],
|
||||
kwargs: {
|
||||
load: "none",
|
||||
context: {
|
||||
abc: 3,
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
},
|
||||
method: "read",
|
||||
model: "sale.order",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.read("sale.order", [2, 5], ["name", "amount"], {
|
||||
load: "none",
|
||||
context: { abc: 3 },
|
||||
});
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/sale.order/read"]);
|
||||
});
|
||||
|
||||
test("unlink method", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [[43]],
|
||||
kwargs: {
|
||||
context: {
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
},
|
||||
method: "unlink",
|
||||
model: "res.partner",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.unlink("res.partner", [43]);
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/res.partner/unlink"]);
|
||||
});
|
||||
|
||||
test("write method", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [[43, 14], { active: false }],
|
||||
kwargs: {
|
||||
context: {
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
},
|
||||
method: "write",
|
||||
model: "res.partner",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.write("res.partner", [43, 14], { active: false });
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/res.partner/write"]);
|
||||
});
|
||||
|
||||
test("webReadGroup method", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [],
|
||||
kwargs: {
|
||||
domain: [["user_id", "=", 2]],
|
||||
fields: ["amount_total:sum"],
|
||||
groupby: ["date_order"],
|
||||
context: {
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
uid: 7,
|
||||
tz: "taht",
|
||||
},
|
||||
offset: 1,
|
||||
},
|
||||
method: "web_read_group",
|
||||
model: "sale.order",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.webReadGroup(
|
||||
"sale.order",
|
||||
[["user_id", "=", 2]],
|
||||
["amount_total:sum"],
|
||||
["date_order"],
|
||||
{ offset: 1 }
|
||||
);
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/sale.order/web_read_group"]);
|
||||
});
|
||||
|
||||
test("readGroup method", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [],
|
||||
kwargs: {
|
||||
domain: [["user_id", "=", 2]],
|
||||
fields: ["amount_total:sum"],
|
||||
groupby: ["date_order"],
|
||||
context: {
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
uid: 7,
|
||||
tz: "taht",
|
||||
},
|
||||
offset: 1,
|
||||
},
|
||||
method: "read_group",
|
||||
model: "sale.order",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.readGroup(
|
||||
"sale.order",
|
||||
[["user_id", "=", 2]],
|
||||
["amount_total:sum"],
|
||||
["date_order"],
|
||||
{ offset: 1 }
|
||||
);
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/sale.order/read_group"]);
|
||||
});
|
||||
|
||||
test("test readGroup method removes duplicate values from groupby", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params.kwargs.groupby).toMatchObject(["date_order:month"], {
|
||||
message: "Duplicate values should be removed from groupby",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.readGroup(
|
||||
"sale.order",
|
||||
[["user_id", "=", 2]],
|
||||
["amount_total:sum"],
|
||||
["date_order:month", "date_order:month"],
|
||||
{ offset: 1 }
|
||||
);
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/sale.order/read_group"]);
|
||||
});
|
||||
|
||||
test("search_read method", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [],
|
||||
kwargs: {
|
||||
context: {
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
domain: [["user_id", "=", 2]],
|
||||
fields: ["amount_total"],
|
||||
},
|
||||
method: "search_read",
|
||||
model: "sale.order",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.searchRead("sale.order", [["user_id", "=", 2]], ["amount_total"]);
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/sale.order/search_read"]);
|
||||
});
|
||||
|
||||
test("search_count method", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [[["user_id", "=", 2]]],
|
||||
kwargs: {
|
||||
context: {
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
},
|
||||
method: "search_count",
|
||||
model: "sale.order",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.searchCount("sale.order", [["user_id", "=", 2]]);
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/sale.order/search_count"]);
|
||||
});
|
||||
|
||||
test("webRead method", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [[2, 5]],
|
||||
kwargs: {
|
||||
specification: { name: {}, amount: {} },
|
||||
context: {
|
||||
abc: 3,
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
},
|
||||
method: "web_read",
|
||||
model: "sale.order",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.webRead("sale.order", [2, 5], {
|
||||
specification: { name: {}, amount: {} },
|
||||
context: { abc: 3 },
|
||||
});
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/sale.order/web_read"]);
|
||||
});
|
||||
|
||||
test("webSearchRead method", async () => {
|
||||
onRpc(async (params) => {
|
||||
expect.step(params.route);
|
||||
expect(params).toMatchObject({
|
||||
args: [],
|
||||
kwargs: {
|
||||
context: {
|
||||
allowed_company_ids: [1],
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
uid: 7,
|
||||
},
|
||||
domain: [["user_id", "=", 2]],
|
||||
specification: { amount_total: {} },
|
||||
},
|
||||
method: "web_search_read",
|
||||
model: "sale.order",
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
await services.orm.webSearchRead("sale.order", [["user_id", "=", 2]], {
|
||||
specification: { amount_total: {} },
|
||||
});
|
||||
|
||||
expect.verifySteps(["/web/dataset/call_kw/sale.order/web_search_read"]);
|
||||
});
|
||||
|
||||
test("orm is specialized for component", async () => {
|
||||
await makeMockEnv();
|
||||
|
||||
class MyComponent extends Component {
|
||||
static props = {};
|
||||
static template = xml`<div />`;
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
}
|
||||
}
|
||||
|
||||
const component = await mountWithCleanup(MyComponent);
|
||||
|
||||
expect(component.orm).not.toBe(getService("orm"));
|
||||
});
|
||||
|
||||
test("silent mode", async () => {
|
||||
onRpc((params) => {
|
||||
expect.step(params.route);
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
after(
|
||||
on(rpcBus, "RPC:RESPONSE", (ev) =>
|
||||
expect.step(`response${ev.detail.settings.silent ? " (silent)" : ""}`)
|
||||
)
|
||||
);
|
||||
|
||||
await services.orm.call("res.partner", "partner_method");
|
||||
await services.orm.silent.call("res.partner", "partner_method");
|
||||
await services.orm.call("res.partner", "partner_method");
|
||||
await services.orm.read("res.partner", [1], []);
|
||||
await services.orm.silent.read("res.partner", [1], []);
|
||||
await services.orm.read("res.partner", [1], []);
|
||||
|
||||
expect.verifySteps([
|
||||
"/web/dataset/call_kw/res.partner/partner_method",
|
||||
"response",
|
||||
"/web/dataset/call_kw/res.partner/partner_method",
|
||||
"response (silent)",
|
||||
"/web/dataset/call_kw/res.partner/partner_method",
|
||||
"response",
|
||||
"/web/dataset/call_kw/res.partner/read",
|
||||
"response",
|
||||
"/web/dataset/call_kw/res.partner/read",
|
||||
"response (silent)",
|
||||
"/web/dataset/call_kw/res.partner/read",
|
||||
"response",
|
||||
]);
|
||||
});
|
||||
|
||||
test("validate some obviously wrong calls", async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
|
||||
expect(() => services.orm.read(false, [3], ["id", "descr"])).toThrow(
|
||||
"Invalid model name: false"
|
||||
);
|
||||
expect(() => services.orm.read("res.res.partner", false, ["id", "descr"])).toThrow(
|
||||
"Invalid ids list: false"
|
||||
);
|
||||
});
|
||||
|
||||
test("optimize read and unlink if no ids", async () => {
|
||||
onRpc((params) => {
|
||||
expect.step(params.route);
|
||||
return false;
|
||||
});
|
||||
|
||||
const { services } = await makeMockEnv();
|
||||
|
||||
await services.orm.read("res.partner", [1], []);
|
||||
expect.verifySteps(["/web/dataset/call_kw/res.partner/read"]);
|
||||
|
||||
await services.orm.read("res.partner", [], []);
|
||||
expect.verifySteps([]);
|
||||
|
||||
await services.orm.unlink("res.partner", [1], {});
|
||||
expect.verifySteps(["/web/dataset/call_kw/res.partner/unlink"]);
|
||||
|
||||
await services.orm.unlink("res.partner", [], {});
|
||||
expect.verifySteps([]);
|
||||
});
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { expect, getFixture, test } from "@odoo/hoot";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, useSubEnv, xml } from "@odoo/owl";
|
||||
import { getService, makeMockEnv, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
|
||||
test("simple case", async () => {
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
expect(".o-overlay-container").toHaveCount(1);
|
||||
|
||||
class MyComp extends Component {
|
||||
static template = xml`
|
||||
<div class="overlayed"></div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
const remove = getService("overlay").add(MyComp, {});
|
||||
await animationFrame();
|
||||
expect(".o-overlay-container .overlayed").toHaveCount(1);
|
||||
|
||||
remove();
|
||||
await animationFrame();
|
||||
expect(".o-overlay-container .overlayed").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shadow DOM overlays are visible when registered before main component is mounted", async () => {
|
||||
class MyComp extends Component {
|
||||
static template = xml`
|
||||
<div class="overlayed"></div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("id", "my-root-id");
|
||||
root.attachShadow({ mode: "open" });
|
||||
getFixture().appendChild(root);
|
||||
|
||||
await makeMockEnv();
|
||||
getService("overlay").add(MyComp, {}, { rootId: "my-root-id" });
|
||||
|
||||
await mountWithCleanup(MainComponentsContainer, { target: root.shadowRoot });
|
||||
await animationFrame();
|
||||
|
||||
expect("#my-root-id:shadow .o-overlay-container .overlayed").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("onRemove callback", async () => {
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
class MyComp extends Component {
|
||||
static template = xml``;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
const onRemove = () => expect.step("onRemove");
|
||||
const remove = getService("overlay").add(MyComp, {}, { onRemove });
|
||||
|
||||
expect.verifySteps([]);
|
||||
remove();
|
||||
expect.verifySteps(["onRemove"]);
|
||||
});
|
||||
|
||||
test("multiple overlays", async () => {
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
class MyComp extends Component {
|
||||
static template = xml`
|
||||
<div class="overlayed" t-att-class="props.className"></div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
const remove1 = getService("overlay").add(MyComp, { className: "o1" });
|
||||
const remove2 = getService("overlay").add(MyComp, { className: "o2" });
|
||||
const remove3 = getService("overlay").add(MyComp, { className: "o3" });
|
||||
await animationFrame();
|
||||
expect(".overlayed").toHaveCount(3);
|
||||
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o1");
|
||||
expect(".o-overlay-container :nth-child(2) .overlayed").toHaveClass("o2");
|
||||
expect(".o-overlay-container :nth-child(3) .overlayed").toHaveClass("o3");
|
||||
|
||||
remove1();
|
||||
await animationFrame();
|
||||
expect(".overlayed").toHaveCount(2);
|
||||
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o2");
|
||||
expect(".o-overlay-container :nth-child(2) .overlayed").toHaveClass("o3");
|
||||
|
||||
remove2();
|
||||
await animationFrame();
|
||||
expect(".overlayed").toHaveCount(1);
|
||||
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o3");
|
||||
|
||||
remove3();
|
||||
await animationFrame();
|
||||
expect(".overlayed").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sequence", async () => {
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
class MyComp extends Component {
|
||||
static template = xml`
|
||||
<div class="overlayed" t-att-class="props.className"></div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
const remove1 = getService("overlay").add(MyComp, { className: "o1" }, { sequence: 50 });
|
||||
const remove2 = getService("overlay").add(MyComp, { className: "o2" }, { sequence: 60 });
|
||||
const remove3 = getService("overlay").add(MyComp, { className: "o3" }, { sequence: 40 });
|
||||
await animationFrame();
|
||||
expect(".overlayed").toHaveCount(3);
|
||||
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o3");
|
||||
expect(".o-overlay-container :nth-child(2) .overlayed").toHaveClass("o1");
|
||||
expect(".o-overlay-container :nth-child(3) .overlayed").toHaveClass("o2");
|
||||
|
||||
remove1();
|
||||
await animationFrame();
|
||||
expect(".overlayed").toHaveCount(2);
|
||||
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o3");
|
||||
expect(".o-overlay-container :nth-child(2) .overlayed").toHaveClass("o2");
|
||||
|
||||
remove2();
|
||||
await animationFrame();
|
||||
expect(".overlayed").toHaveCount(1);
|
||||
expect(".o-overlay-container :nth-child(1) .overlayed").toHaveClass("o3");
|
||||
|
||||
remove3();
|
||||
await animationFrame();
|
||||
expect(".overlayed").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("allow env as option", async () => {
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
|
||||
class MyComp extends Component {
|
||||
static props = ["*"];
|
||||
static template = xml`
|
||||
<ul class="outer">
|
||||
<li>A=<t t-out="env.A"/></li>
|
||||
<li>B=<t t-out="env.B"/></li>
|
||||
</ul>
|
||||
`;
|
||||
setup() {
|
||||
useSubEnv({ A: "blip" });
|
||||
}
|
||||
}
|
||||
|
||||
getService("overlay").add(MyComp, {}, { env: { A: "foo", B: "bar" } });
|
||||
await animationFrame();
|
||||
|
||||
expect(".o-overlay-container li:nth-child(1)").toHaveText("A=blip");
|
||||
expect(".o-overlay-container li:nth-child(2)").toHaveText("B=bar");
|
||||
});
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
import { expect, getFixture, test } from "@odoo/hoot";
|
||||
import { queryOne, resize, scroll, waitFor } from "@odoo/hoot-dom";
|
||||
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { Popover } from "@web/core/popover/popover";
|
||||
import { usePosition } from "@web/core/position/position_hook";
|
||||
|
||||
class Content extends Component {
|
||||
static props = ["*"];
|
||||
static template = xml`<div id="popover">Popover Content</div>`;
|
||||
}
|
||||
|
||||
test("popover can have custom class", async () => {
|
||||
await mountWithCleanup(Popover, {
|
||||
props: {
|
||||
target: getFixture(),
|
||||
class: "custom-popover",
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_popover.custom-popover").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("popover can have more than one custom class", async () => {
|
||||
await mountWithCleanup(Popover, {
|
||||
props: {
|
||||
target: getFixture(),
|
||||
class: "custom-popover popover-custom",
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_popover.custom-popover.popover-custom").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("popover is rendered nearby target (default)", async () => {
|
||||
expect.assertions(2);
|
||||
class TestPopover extends Popover {
|
||||
onPositioned(el, { direction, variant }) {
|
||||
expect(direction).toBe("bottom");
|
||||
expect(variant).toBe("middle");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: getFixture(),
|
||||
class: "custom-popover popover-custom",
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("popover is rendered nearby target (bottom)", async () => {
|
||||
expect.assertions(2);
|
||||
class TestPopover extends Popover {
|
||||
onPositioned(el, { direction, variant }) {
|
||||
expect(direction).toBe("bottom");
|
||||
expect(variant).toBe("middle");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: getFixture(),
|
||||
position: "bottom",
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("popover is rendered nearby target (top)", async () => {
|
||||
expect.assertions(2);
|
||||
class TestPopover extends Popover {
|
||||
onPositioned(el, { direction, variant }) {
|
||||
expect(direction).toBe("top");
|
||||
expect(variant).toBe("middle");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: getFixture(),
|
||||
position: "top",
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("popover is rendered nearby target (left)", async () => {
|
||||
expect.assertions(2);
|
||||
class TestPopover extends Popover {
|
||||
onPositioned(el, { direction, variant }) {
|
||||
expect(direction).toBe("left");
|
||||
expect(variant).toBe("middle");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: getFixture(),
|
||||
position: "left",
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("popover is rendered nearby target (right)", async () => {
|
||||
expect.assertions(2);
|
||||
class TestPopover extends Popover {
|
||||
onPositioned(el, { direction, variant }) {
|
||||
expect(direction).toBe("right");
|
||||
expect(variant).toBe("middle");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: getFixture(),
|
||||
position: "right",
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("popover is rendered nearby target (bottom-start)", async () => {
|
||||
expect.assertions(2);
|
||||
class TestPopover extends Popover {
|
||||
onPositioned(el, { direction, variant }) {
|
||||
expect(direction).toBe("bottom");
|
||||
expect(variant).toBe("start");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: getFixture(),
|
||||
position: "bottom-start",
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("popover is rendered nearby target (bottom-middle)", async () => {
|
||||
expect.assertions(2);
|
||||
class TestPopover extends Popover {
|
||||
onPositioned(el, { direction, variant }) {
|
||||
expect(direction).toBe("bottom");
|
||||
expect(variant).toBe("middle");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: getFixture(),
|
||||
position: "bottom-middle",
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("popover is rendered nearby target (bottom-end)", async () => {
|
||||
expect.assertions(2);
|
||||
class TestPopover extends Popover {
|
||||
onPositioned(el, { direction, variant }) {
|
||||
expect(direction).toBe("bottom");
|
||||
expect(variant).toBe("end");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: getFixture(),
|
||||
position: "bottom-end",
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("popover is rendered nearby target (bottom-fit)", async () => {
|
||||
expect.assertions(2);
|
||||
class TestPopover extends Popover {
|
||||
onPositioned(el, { direction, variant }) {
|
||||
expect(direction).toBe("bottom");
|
||||
expect(variant).toBe("fit");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: getFixture(),
|
||||
position: "bottom-fit",
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("reposition popover should properly change classNames", async () => {
|
||||
await resize({ height: 300 });
|
||||
|
||||
class TestPopover extends Popover {
|
||||
setup() {
|
||||
// Don't call super.setup() in order to replace the use of usePosition hook...
|
||||
usePosition("ref", () => this.props.target, {
|
||||
container,
|
||||
onPositioned: this.onPositioned.bind(this),
|
||||
position: this.props.position,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Force some style, to make this test independent of screen size
|
||||
await mountWithCleanup(/* xml */ `
|
||||
<div class="container" style="width: 450px; height: 450px; display: flex; align-items: center; justify-content: center;">
|
||||
<div class="popover-target" style="width: 50px; height: 50px;" />
|
||||
</div>
|
||||
`);
|
||||
|
||||
const container = queryOne(".container");
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: queryOne(".popover-target"),
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
|
||||
const popover = queryOne("#popover");
|
||||
popover.style.height = "100px";
|
||||
popover.style.width = "100px";
|
||||
|
||||
// Should have classes for a "bottom-middle" placement
|
||||
expect(".o_popover").toHaveClass(
|
||||
"o_popover popover mw-100 o-popover--with-arrow bs-popover-bottom o-popover-bottom o-popover--bm"
|
||||
);
|
||||
expect(".popover-arrow").toHaveClass("popover-arrow start-0 end-0 mx-auto");
|
||||
|
||||
// Change container style and force update
|
||||
container.style.height = "125px"; // height of popper + 1/2 reference
|
||||
container.style.alignItems = "flex-end";
|
||||
await resize();
|
||||
await runAllTimers();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveClass(
|
||||
"o_popover popover mw-100 o-popover--with-arrow bs-popover-end o-popover-right o-popover--re"
|
||||
);
|
||||
expect(".popover-arrow").toHaveClass("popover-arrow top-auto");
|
||||
});
|
||||
|
||||
test("within iframe", async () => {
|
||||
let popoverEl;
|
||||
class TestPopover extends Popover {
|
||||
onPositioned(el, { direction }) {
|
||||
popoverEl = el;
|
||||
expect.step(direction);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(/* xml */ `
|
||||
<iframe class="container" style="height: 200px; display: flex" srcdoc="<div id='target' style='height:400px;'>Within iframe</div>" />
|
||||
`);
|
||||
|
||||
await waitFor(":iframe #target");
|
||||
|
||||
const popoverTarget = queryOne(":iframe #target");
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: popoverTarget,
|
||||
component: Content,
|
||||
animation: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect.verifySteps(["bottom"]);
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(":iframe .o_popover").toHaveCount(0);
|
||||
|
||||
// The popover should be rendered in the correct position
|
||||
const marginTop = parseFloat(getComputedStyle(popoverEl).marginTop);
|
||||
const { top: targetTop, left: targetLeft } = popoverTarget.getBoundingClientRect();
|
||||
const { top: iframeTop, left: iframeLeft } = queryOne("iframe").getBoundingClientRect();
|
||||
let popoverBox = popoverEl.getBoundingClientRect();
|
||||
let expectedTop = iframeTop + targetTop + popoverTarget.offsetHeight + marginTop;
|
||||
const expectedLeft =
|
||||
iframeLeft + targetLeft + (popoverTarget.offsetWidth - popoverBox.width) / 2;
|
||||
expect(Math.floor(popoverBox.top)).toBe(Math.floor(expectedTop));
|
||||
expect(Math.floor(popoverBox.left)).toBe(Math.floor(expectedLeft));
|
||||
|
||||
await scroll(popoverTarget.ownerDocument.documentElement, { y: 100 }, { scrollable: false });
|
||||
await animationFrame();
|
||||
expect.verifySteps(["bottom"]);
|
||||
popoverBox = popoverEl.getBoundingClientRect();
|
||||
expectedTop -= 100;
|
||||
expect(Math.floor(popoverBox.top)).toBe(Math.floor(expectedTop));
|
||||
expect(Math.floor(popoverBox.left)).toBe(Math.floor(expectedLeft));
|
||||
});
|
||||
|
||||
test("within iframe -- wrong element class", async () => {
|
||||
class TestPopover extends Popover {
|
||||
static props = {
|
||||
...Popover.props,
|
||||
target: {
|
||||
validate: (...args) => {
|
||||
const val = Popover.props.target.validate(...args);
|
||||
expect.step(`validate target props: "${val}"`);
|
||||
return val;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await mountWithCleanup(/* xml */ `
|
||||
<iframe class="container" style="height: 200px; display: flex" srcdoc="<div id='target' style='height:400px;'>Within iframe</div>" />
|
||||
`);
|
||||
|
||||
await waitFor(":iframe #target");
|
||||
|
||||
const wrongElement = document.createElement("div");
|
||||
wrongElement.classList.add("wrong-element");
|
||||
queryOne(":iframe body").appendChild(wrongElement);
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: wrongElement,
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect.verifySteps(['validate target props: "true"']);
|
||||
});
|
||||
|
||||
test("popover fixed position", async () => {
|
||||
class TestPopover extends Popover {
|
||||
onPositioned() {
|
||||
expect.step("onPositioned");
|
||||
}
|
||||
}
|
||||
|
||||
await resize({ width: 450, height: 450 });
|
||||
await mountWithCleanup(/* xml */ `
|
||||
<div class="container w-100 h-100" style="display: flex">
|
||||
<div class="popover-target" style="width: 50px; height: 50px;" />
|
||||
</div>
|
||||
`);
|
||||
|
||||
const container = queryOne(".container");
|
||||
|
||||
await mountWithCleanup(TestPopover, {
|
||||
props: {
|
||||
target: container,
|
||||
position: "bottom-fit",
|
||||
fixedPosition: true,
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect.verifySteps(["onPositioned"]);
|
||||
|
||||
// force the DOM update
|
||||
container.style.alignItems = "flex-end";
|
||||
await resize({ height: 125 });
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps([]);
|
||||
});
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { test, expect, getFixture, destroy } from "@odoo/hoot";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
test("close popover when component is unmounted", async () => {
|
||||
const target = getFixture();
|
||||
class Comp extends Component {
|
||||
static template = xml`<div t-att-id="props.id">in popover</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
class CompWithPopover extends Component {
|
||||
static template = xml`<div />`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.popover = usePopover(Comp);
|
||||
}
|
||||
}
|
||||
|
||||
const comp1 = await mountWithCleanup(CompWithPopover);
|
||||
comp1.popover.open(target, { id: "comp1" });
|
||||
await animationFrame();
|
||||
|
||||
const comp2 = await mountWithCleanup(CompWithPopover, { noMainContainer: true });
|
||||
comp2.popover.open(target, { id: "comp2" });
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(2);
|
||||
expect(".o_popover #comp1").toHaveCount(1);
|
||||
expect(".o_popover #comp2").toHaveCount(1);
|
||||
|
||||
destroy(comp1);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(".o_popover #comp1").toHaveCount(0);
|
||||
expect(".o_popover #comp2").toHaveCount(1);
|
||||
|
||||
destroy(comp2);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
expect(".o_popover #comp1").toHaveCount(0);
|
||||
expect(".o_popover #comp2").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("popover opened from another", async () => {
|
||||
class Comp extends Component {
|
||||
static id = 0;
|
||||
static template = xml`
|
||||
<div class="p-4">
|
||||
<button class="pop-open" t-on-click="(ev) => this.popover.open(ev.target, {})">open popover</button>
|
||||
</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.popover = usePopover(Comp, {
|
||||
popoverClass: `popover-${++Comp.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Comp);
|
||||
|
||||
await contains(".pop-open").click();
|
||||
expect(".popover-1").toHaveCount(1);
|
||||
|
||||
await contains(".popover-1 .pop-open").click();
|
||||
expect(".o_popover").toHaveCount(2);
|
||||
expect(".popover-1").toHaveCount(1);
|
||||
expect(".popover-2").toHaveCount(1);
|
||||
|
||||
await contains(".popover-2 .pop-open").click();
|
||||
expect(".o_popover").toHaveCount(3);
|
||||
expect(".popover-1").toHaveCount(1);
|
||||
expect(".popover-2").toHaveCount(1);
|
||||
expect(".popover-3").toHaveCount(1);
|
||||
|
||||
await contains(".popover-3").click();
|
||||
expect(".o_popover").toHaveCount(3);
|
||||
expect(".popover-1").toHaveCount(1);
|
||||
expect(".popover-2").toHaveCount(1);
|
||||
expect(".popover-3").toHaveCount(1);
|
||||
|
||||
await contains(".popover-2").click();
|
||||
expect(".o_popover").toHaveCount(2);
|
||||
expect(".popover-1").toHaveCount(1);
|
||||
expect(".popover-2").toHaveCount(1);
|
||||
|
||||
await contains(document.body).click();
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
import { Component, onWillStart, xml } from "@odoo/owl";
|
||||
import { test, expect, beforeEach, getFixture } from "@odoo/hoot";
|
||||
import { getService, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { click, press } from "@odoo/hoot-dom";
|
||||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
|
||||
let target;
|
||||
|
||||
beforeEach(async () => {
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
target = getFixture();
|
||||
});
|
||||
|
||||
test("simple use", async () => {
|
||||
class Comp extends Component {
|
||||
static template = xml`<div id="comp">in popover</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
|
||||
const remove = getService("popover").add(target, Comp);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(".o_popover #comp").toHaveCount(1);
|
||||
|
||||
remove();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
expect(".o_popover #comp").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("close on click away", async () => {
|
||||
class Comp extends Component {
|
||||
static template = xml`<div id="comp">in popover</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
getService("popover").add(target, Comp);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(".o_popover #comp").toHaveCount(1);
|
||||
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
expect(".o_popover #comp").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("close on click away when loading", async () => {
|
||||
const def = new Deferred();
|
||||
class Comp extends Component {
|
||||
static template = xml`<div id="comp">in popover</div>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
onWillStart(async () => {
|
||||
await def;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getService("popover").add(target, Comp);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
expect(".o_popover #comp").toHaveCount(0);
|
||||
|
||||
click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
expect(".o_popover #comp").toHaveCount(0);
|
||||
|
||||
def.resolve();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
expect(".o_popover #comp").toHaveCount(0);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("close on 'Escape' keydown", async () => {
|
||||
class Comp extends Component {
|
||||
static template = xml`<div id="comp">in popover</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
getService("popover").add(target, Comp);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(".o_popover #comp").toHaveCount(1);
|
||||
|
||||
await press("Escape");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
expect(".o_popover #comp").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("do not close on click away", async () => {
|
||||
class Comp extends Component {
|
||||
static template = xml`<div id="comp">in popover</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
const remove = getService("popover").add(target, Comp, {}, { closeOnClickAway: false });
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(".o_popover #comp").toHaveCount(1);
|
||||
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(".o_popover #comp").toHaveCount(1);
|
||||
|
||||
remove();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
expect(".o_popover #comp").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("close callback", async () => {
|
||||
class Comp extends Component {
|
||||
static template = xml`<div id="comp">in popover</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
expect.step("close");
|
||||
}
|
||||
|
||||
getService("popover").add(target, Comp, {}, { onClose });
|
||||
await animationFrame();
|
||||
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["close"]);
|
||||
});
|
||||
|
||||
test("sub component triggers close", async () => {
|
||||
class Comp extends Component {
|
||||
static template = xml`<div id="comp" t-on-click="() => this.props.close()">in popover</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
getService("popover").add(target, Comp);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(".o_popover #comp").toHaveCount(1);
|
||||
|
||||
await click("#comp");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
expect(".o_popover #comp").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("close popover if target is removed", async () => {
|
||||
class Comp extends Component {
|
||||
static template = xml`<div id="comp">in popover</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
getService("popover").add(target, Comp);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(".o_popover #comp").toHaveCount(1);
|
||||
|
||||
target.remove();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
expect(".o_popover #comp").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("close and do not crash if target parent does not exist", async () => {
|
||||
// This target does not have any parent, it simulates the case where the element disappeared
|
||||
// from the DOM before the setup of the component
|
||||
const dissapearedTarget = document.createElement("div");
|
||||
|
||||
class Comp extends Component {
|
||||
static template = xml`<div id="comp">in popover</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
expect.step("close");
|
||||
}
|
||||
|
||||
getService("popover").add(dissapearedTarget, Comp, {}, { onClose });
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["close"]);
|
||||
});
|
||||
|
||||
test("keep popover if target sibling is removed", async () => {
|
||||
class Comp extends Component {
|
||||
static template = xml`<div id="comp">in popover</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
class Sibling extends Component {
|
||||
static template = xml`<div id="sibling">Sibling</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Sibling, { noMainContainer: true });
|
||||
|
||||
getService("popover").add(target, Comp);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(".o_popover #comp").toHaveCount(1);
|
||||
|
||||
target.querySelector("#sibling").remove();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(".o_popover #comp").toHaveCount(1);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue