vanilla 18.0

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,67 @@
import { describe, expect, getFixture, test } from "@odoo/hoot";
import { mockFetch } from "@odoo/hoot-mock";
import { getService, makeMockEnv, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
describe.current.tags("headless");
const mountManifestLink = (href) => {
const fixture = getFixture();
const manifestLink = document.createElement("link");
manifestLink.rel = "manifest";
manifestLink.href = href;
fixture.append(manifestLink);
};
test("PWA service fetches the manifest found in the page", async () => {
await makeMockEnv();
mountManifestLink("/web/manifest.webmanifest");
mockFetch((route) => {
expect.step(route);
return { name: "Odoo PWA" };
});
const pwaService = await getService("pwa");
let appManifest = await pwaService.getManifest();
expect(appManifest).toEqual({ name: "Odoo PWA" });
expect.verifySteps(["/web/manifest.webmanifest"]);
appManifest = await pwaService.getManifest();
expect(appManifest).toEqual({ name: "Odoo PWA" });
// manifest is only fetched once to get the app name
expect.verifySteps([]);
});
test("PWA installation process", async () => {
const beforeInstallPromptEvent = new CustomEvent("beforeinstallprompt");
beforeInstallPromptEvent.preventDefault = () => {};
beforeInstallPromptEvent.prompt = async () => ({ outcome: "accepted" });
browser.BeforeInstallPromptEvent = beforeInstallPromptEvent;
await makeMockEnv();
mountManifestLink("/web/manifest.scoped_app_manifest");
mockFetch((route) => {
expect.step(route);
return { name: "My App", scope: "/scoped_app/myApp", start_url: "/scoped_app/myApp" };
});
patchWithCleanup(browser.localStorage, {
setItem(key, value) {
if (key === "pwaService.installationState") {
expect.step(value);
return null;
}
return super.setItem(key, value);
},
});
const pwaService = await getService("pwa");
expect(pwaService.isAvailable).toBe(false);
expect(pwaService.canPromptToInstall).toBe(false);
browser.dispatchEvent(beforeInstallPromptEvent);
expect(pwaService.isAvailable).toBe(true);
expect(pwaService.canPromptToInstall).toBe(true);
await pwaService.show({
onDone: (res) => {
expect.step("onDone call with installation " + res.outcome);
},
});
expect(pwaService.canPromptToInstall).toBe(false);
expect.verifySteps(['{"/odoo":"accepted"}', "onDone call with installation accepted"]);
});

View file

@ -0,0 +1,420 @@
import { describe, expect, test } from "@odoo/hoot";
import { mockDate } from "@odoo/hoot-mock";
import { evaluateExpr } from "@web/core/py_js/py";
import { PyDate, PyTimeDelta } from "@web/core/py_js/py_date";
const check = (expr, fn) => {
const d0 = new Date();
const result = evaluateExpr(expr);
const d1 = new Date();
return fn(d0) <= result && result <= fn(d1);
};
const format = (n) => String(n).padStart(2, "0");
const formatDate = (d) => {
const year = d.getFullYear();
const month = format(d.getMonth() + 1);
const day = format(d.getDate());
return `${year}-${month}-${day}`;
};
const formatDateTime = (d) => {
const h = format(d.getHours());
const m = format(d.getMinutes());
const s = format(d.getSeconds());
return `${formatDate(d)} ${h}:${m}:${s}`;
};
describe.current.tags("headless");
describe("time", () => {
test("strftime", () => {
expect(check("time.strftime('%Y')", (d) => String(d.getFullYear()))).toBe(true);
expect(
check("time.strftime('%Y') + '-01-30'", (d) => String(d.getFullYear()) + "-01-30")
).toBe(true);
expect(check("time.strftime('%Y-%m-%d %H:%M:%S')", formatDateTime)).toBe(true);
});
});
describe("datetime.datetime", () => {
test("datetime.datetime.now", () => {
expect(check("datetime.datetime.now().year", (d) => d.getFullYear())).toBe(true);
expect(check("datetime.datetime.now().month", (d) => d.getMonth() + 1)).toBe(true);
expect(check("datetime.datetime.now().day", (d) => d.getDate())).toBe(true);
expect(check("datetime.datetime.now().hour", (d) => d.getHours())).toBe(true);
expect(check("datetime.datetime.now().minute", (d) => d.getMinutes())).toBe(true);
expect(check("datetime.datetime.now().second", (d) => d.getSeconds())).toBe(true);
});
test("various operations", () => {
const expr1 = "datetime.datetime(day=3,month=4,year=2001).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr1)).toBe("2001-04-03");
const expr2 = "datetime.datetime(2001, 4, 3).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr2)).toBe("2001-04-03");
const expr3 =
"datetime.datetime(day=3,month=4,second=12, year=2001,minute=32).strftime('%Y-%m-%d %H:%M:%S')";
expect(evaluateExpr(expr3)).toBe("2001-04-03 00:32:12");
});
test("to_utc", () => {
mockDate("2021-09-17 10:00:00", +6);
const expr = "datetime.datetime.combine(context_today(), datetime.time(0,0,0)).to_utc()";
expect(JSON.stringify(evaluateExpr(expr))).toBe(`"2021-09-16 18:00:00"`);
});
test("to_utc in october with winter/summer change", () => {
mockDate("2021-10-17 10:00:00", "Europe/Brussels");
const expr = "datetime.datetime(2022, 10, 17).to_utc()";
expect(JSON.stringify(evaluateExpr(expr))).toBe(`"2022-10-16 22:00:00"`);
});
test("datetime.datetime.combine", () => {
const expr =
"datetime.datetime.combine(context_today(), datetime.time(23,59,59)).strftime('%Y-%m-%d %H:%M:%S')";
expect(
check(expr, (d) => {
return formatDate(d) + " 23:59:59";
})
).toBe(true);
});
test("datetime.datetime.toJSON", () => {
expect(
JSON.stringify(evaluateExpr("datetime.datetime(day=3,month=4,year=2001,hour=10)"))
).toBe(`"2001-04-03 10:00:00"`);
});
test("datetime + timedelta", function () {
expect.assertions(6);
expect(
evaluateExpr(
"(datetime.datetime(2017, 2, 15, 1, 7, 31) + datetime.timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"
)
).toBe("2017-02-16 01:07:31");
expect(
evaluateExpr(
"(datetime.datetime(2012, 2, 15, 1, 7, 31) - datetime.timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S')"
)
).toBe("2012-02-15 00:07:31");
expect(
evaluateExpr(
"(datetime.datetime(2012, 2, 15, 1, 7, 31) + datetime.timedelta(hours=-1)).strftime('%Y-%m-%d %H:%M:%S')"
)
).toBe("2012-02-15 00:07:31");
expect(
evaluateExpr(
"(datetime.datetime(2012, 2, 15, 1, 7, 31) + datetime.timedelta(minutes=100)).strftime('%Y-%m-%d %H:%M:%S')"
)
).toBe("2012-02-15 02:47:31");
expect(
evaluateExpr(
"(datetime.date(day=3,month=4,year=2001) + datetime.timedelta(days=-1)).strftime('%Y-%m-%d')"
)
).toBe("2001-04-02");
expect(
evaluateExpr(
"(datetime.timedelta(days=-1) + datetime.date(day=3,month=4,year=2001)).strftime('%Y-%m-%d')"
)
).toBe("2001-04-02");
});
});
describe("datetime.date", () => {
test("datetime.date.today", () => {
expect(check("(datetime.date.today()).strftime('%Y-%m-%d')", formatDate)).toBe(true);
});
test("various operations", () => {
const expr1 = "datetime.date(day=3,month=4,year=2001).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr1)).toBe("2001-04-03");
const expr2 = "datetime.date(2001, 4, 3).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr2)).toBe("2001-04-03");
});
test("datetime.date.toJSON", () => {
expect(JSON.stringify(evaluateExpr("datetime.date(year=1997,month=5,day=18)"))).toBe(
`"1997-05-18"`
);
});
test("basic operations with dates", function () {
expect.assertions(19);
let ctx = {
d1: PyDate.create(2002, 1, 31),
d2: PyDate.create(1956, 1, 31),
};
expect(evaluateExpr("(d1 - d2).days", ctx)).toBe(46 * 365 + 12);
expect(evaluateExpr("(d1 - d2).seconds", ctx)).toBe(0);
expect(evaluateExpr("(d1 - d2).microseconds", ctx)).toBe(0);
ctx = {
a: PyDate.create(2002, 3, 2),
day: PyTimeDelta.create({ days: 1 }),
week: PyTimeDelta.create({ days: 7 }),
date: PyDate,
};
expect(evaluateExpr("a + day == date(2002, 3, 3)", ctx)).toBe(true);
expect(evaluateExpr("day + a == date(2002, 3, 3)", ctx)).toBe(true); // 5
expect(evaluateExpr("a - day == date(2002, 3, 1)", ctx)).toBe(true);
expect(evaluateExpr("-day + a == date(2002, 3, 1)", ctx)).toBe(true);
expect(evaluateExpr("a + week == date(2002, 3, 9)", ctx)).toBe(true);
expect(evaluateExpr("a - week == date(2002, 2, 23)", ctx)).toBe(true);
expect(evaluateExpr("a + 52*week == date(2003, 3, 1)", ctx)).toBe(true); // 10
expect(evaluateExpr("a - 52*week == date(2001, 3, 3)", ctx)).toBe(true);
expect(evaluateExpr("(a + week) - a == week", ctx)).toBe(true);
expect(evaluateExpr("(a + day) - a == day", ctx)).toBe(true);
expect(evaluateExpr("(a - week) - a == -week", ctx)).toBe(true);
expect(evaluateExpr("(a - day) - a == -day", ctx)).toBe(true); // 15
expect(evaluateExpr("a - (a + week) == -week", ctx)).toBe(true);
expect(evaluateExpr("a - (a + day) == -day", ctx)).toBe(true);
expect(evaluateExpr("a - (a - week) == week", ctx)).toBe(true);
expect(evaluateExpr("a - (a - day) == day", ctx)).toBe(true);
// expect(() => evaluateExpr("a + 1", ctx)).toThrow(/^Error: TypeError:/); //20
// expect(() => evaluateExpr("a - 1", ctx)).toThrow(/^Error: TypeError:/);
// expect(() => evaluateExpr("1 + a", ctx)).toThrow(/^Error: TypeError:/);
// expect(() => evaluateExpr("1 - a", ctx)).toThrow(/^Error: TypeError:/);
// // delta - date is senseless.
// expect(() => evaluateExpr("day - a", ctx)).toThrow(/^Error: TypeError:/);
// // mixing date and (delta or date) via * or // is senseless
// expect(() => evaluateExpr("day * a", ctx)).toThrow(/^Error: TypeError:/); // 25
// expect(() => evaluateExpr("a * day", ctx)).toThrow(/^Error: TypeError:/);
// expect(() => evaluateExpr("day // a", ctx)).toThrow(/^Error: TypeError:/);
// expect(() => evaluateExpr("a // day", ctx)).toThrow(/^Error: TypeError:/);
// expect(() => evaluateExpr("a * a", ctx)).toThrow(/^Error: TypeError:/);
// expect(() => evaluateExpr("a // a", ctx)).toThrow(/^Error: TypeError:/); // 30
// // date + date is senseless
// expect(() => evaluateExpr("a + a", ctx)).toThrow(/^Error: TypeError:/);
});
});
describe("datetime.time", () => {
test("various operations", () => {
const expr1 = "datetime.time(hour=3,minute=2. second=1).strftime('%H:%M:%S')";
expect(evaluateExpr(expr1)).toBe("03:02:01");
});
test("attributes", () => {
const expr1 = "datetime.time(hour=3,minute=2. second=1).hour";
expect(evaluateExpr(expr1)).toBe(3);
const expr2 = "datetime.time(hour=3,minute=2. second=1).minute";
expect(evaluateExpr(expr2)).toBe(2);
const expr3 = "datetime.time(hour=3,minute=2. second=1).second";
expect(evaluateExpr(expr3)).toBe(1);
});
test("datetime.time.toJSON", () => {
expect(JSON.stringify(evaluateExpr("datetime.time(hour=11,minute=45,second=15)"))).toBe(
`"11:45:15"`
);
});
});
describe("relativedelta relative : period is plural", () => {
test("adding date and relative delta", () => {
const expr1 =
"(datetime.date(day=3,month=4,year=2001) + relativedelta(days=-1)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr1)).toBe("2001-04-02");
const expr2 =
"(datetime.date(day=3,month=4,year=2001) + relativedelta(weeks=-1)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr2)).toBe("2001-03-27");
});
test("adding relative delta and date", () => {
const expr =
"(relativedelta(days=-1) + datetime.date(day=3,month=4,year=2001)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr)).toBe("2001-04-02");
});
test("adding/substracting relative delta and date -- shifts order of magnitude", () => {
const expr =
"(relativedelta(hours=14) + datetime.datetime(hour=15,day=3,month=4,year=2001)).strftime('%Y-%m-%d %H:%M:%S')";
expect(evaluateExpr(expr)).toBe("2001-04-04 05:00:00");
const expr2 =
"(relativedelta(days=32) + datetime.date(day=3,month=4,year=2001)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr2)).toBe("2001-05-05");
const expr3 =
"(relativedelta(months=14) + datetime.date(day=3,month=4,year=2001)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr3)).toBe("2002-06-03");
const expr4 =
"(datetime.datetime(hour=13,day=3,month=4,year=2001) - relativedelta(hours=14)).strftime('%Y-%m-%d %H:%M:%S')";
expect(evaluateExpr(expr4)).toBe("2001-04-02 23:00:00");
const expr5 =
"(datetime.date(day=3,month=4,year=2001) - relativedelta(days=4)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr5)).toBe("2001-03-30");
const expr6 =
"(datetime.date(day=3,month=4,year=2001) - relativedelta(months=5)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr6)).toBe("2000-11-03");
});
test("substracting date and relative delta", () => {
const expr1 =
"(datetime.date(day=3,month=4,year=2001) - relativedelta(days=-1)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr1)).toBe("2001-04-04");
const expr2 =
"(datetime.date(day=3,month=4,year=2001) - relativedelta(weeks=-1)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr2)).toBe("2001-04-10");
const expr3 =
"(datetime.date(day=3,month=4,year=2001) - relativedelta(days=1)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr3)).toBe("2001-04-02");
const expr4 =
"(datetime.date(day=3,month=4,year=2001) - relativedelta(weeks=1)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr4)).toBe("2001-03-27");
});
});
describe("relativedelta absolute : period is singular", () => {
test("throws when period negative", () => {
const expr1 = "relativedelta(day=-1)";
expect(() => evaluateExpr(expr1)).toThrow("day -1 is out of range");
const expr2 = "relativedelta(month=-1)";
expect(() => evaluateExpr(expr2)).toThrow("month -1 is out of range");
});
test("adding date and relative delta", () => {
const expr1 =
"(datetime.date(day=3,month=4,year=2001) + relativedelta(day=1)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr1)).toBe("2001-04-01");
const expr2 =
"(datetime.date(day=3,month=4,year=2001) + relativedelta(month=1)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr2)).toBe("2001-01-03");
const expr3 =
"(datetime.date(2021,10,1) + relativedelta(hours=12)).strftime('%Y-%m-%d %H:%M:%S')";
expect(evaluateExpr(expr3)).toBe("2021-10-01 12:00:00");
const expr4 =
"(datetime.date(2021,10,1) + relativedelta(day=15,days=3)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr4)).toBe("2021-10-18");
const expr5 =
"(datetime.date(2021,10,1) - relativedelta(day=15,days=3)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr5)).toBe("2021-10-12");
const expr6 =
"(datetime.date(2021,10,1) + relativedelta(day=15,days=3,hours=24)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr6)).toBe("2021-10-19");
});
test("adding relative delta and date", () => {
const expr =
"(relativedelta(day=1) + datetime.date(day=3,month=4,year=2001)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr)).toBe("2001-04-01");
});
test("substracting date and relative delta", () => {
const expr1 =
"(datetime.date(day=3,month=4,year=2001) - relativedelta(day=1)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr1)).toBe("2001-04-01");
const expr3 =
"(datetime.date(day=3,month=4,year=2001) - relativedelta(day=1)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr3)).toBe("2001-04-01");
});
test("type of date + relative delta", () => {
const expr1 = "(datetime.date(2021,10,1) + relativedelta(day=15,days=3,hours=24))";
expect(evaluateExpr(expr1)).toBeInstanceOf(PyDate);
});
});
describe("relative delta weekday", () => {
test("add or substract weekday", () => {
const expr1 =
"(datetime.date(day=3,month=4,year=2001) - relativedelta(day=1, weekday=3)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr1)).toBe("2001-04-05");
const expr2 =
"(datetime.date(day=29,month=4,year=2001) - relativedelta(weekday=4)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr2)).toBe("2001-05-04");
const expr3 =
"(datetime.date(day=6,month=4,year=2001) - relativedelta(weekday=0)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr3)).toBe("2001-04-09");
const expr4 =
"(datetime.date(day=1,month=4,year=2001) + relativedelta(weekday=-2)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr4)).toBe("2001-04-07");
const expr5 =
"(datetime.date(day=11,month=4,year=2001) + relativedelta(weekday=2)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr5)).toBe("2001-04-11");
const expr6 =
"(datetime.date(day=11,month=4,year=2001) + relativedelta(weekday=-2)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr6)).toBe("2001-04-14");
const expr7 =
"(datetime.date(day=11,month=4,year=2001) + relativedelta(weekday=0)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr7)).toBe("2001-04-16");
const expr8 =
"(datetime.date(day=11,month=4,year=2001) + relativedelta(weekday=1)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr8)).toBe("2001-04-17");
});
});
describe("relative delta yearday nlyearday", () => {
test("yearday", () => {
const expr1 =
"(datetime.date(day=3,month=4,year=2001) - relativedelta(year=2000, yearday=60)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr1)).toBe("2000-02-29");
const expr2 =
"(datetime.date(day=3,month=4,year=2001) - relativedelta(yearday=60)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr2)).toBe("2001-03-01");
const expr3 =
"(datetime.date(1999,12,31) + relativedelta(days=1, yearday=60)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr3)).toBe("1999-03-02");
});
test("nlyearday", () => {
const expr1 =
"(datetime.date(day=3,month=4,year=2001) + relativedelta(year=2000, nlyearday=60)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr1)).toBe("2000-03-01");
const expr2 =
"(datetime.date(day=3,month=4,year=2001) + relativedelta(nlyearday=60)).strftime('%Y-%m-%d')";
expect(evaluateExpr(expr2)).toBe("2001-03-01");
});
});
describe("misc", () => {
test("context_today", () => {
expect(check("context_today().strftime('%Y-%m-%d')", formatDate)).toBe(true);
});
test("today", () => {
expect(check("today", formatDate)).toBe(true);
});
test("now", () => {
expect(check("now", formatDateTime)).toBe(true);
});
test("current_date", () => {
mockDate("2021-09-20 10:00:00");
expect(evaluateExpr("current_date")).toBe("2021-09-20");
});
});

View file

@ -0,0 +1,509 @@
import { describe, expect, test } from "@odoo/hoot";
import { evaluateBooleanExpr, evaluateExpr } from "@web/core/py_js/py";
describe.current.tags("headless");
describe("basic values", () => {
test("evaluate simple values", () => {
expect(evaluateExpr("12")).toBe(12);
expect(evaluateExpr('"foo"')).toBe("foo");
});
test("empty expression", () => {
expect(() => evaluateExpr("")).toThrow(/Error: Missing token/);
});
test("numbers", () => {
expect(evaluateExpr("1.2")).toBe(1.2);
expect(evaluateExpr(".12")).toBe(0.12);
expect(evaluateExpr("0")).toBe(0);
expect(evaluateExpr("1.0")).toBe(1);
expect(evaluateExpr("-1.2")).toBe(-1.2);
expect(evaluateExpr("-12")).toBe(-12);
expect(evaluateExpr("+12")).toBe(12);
});
test("strings", () => {
expect(evaluateExpr('""')).toBe("");
expect(evaluateExpr('"foo"')).toBe("foo");
expect(evaluateExpr("'foo'")).toBe("foo");
expect(evaluateExpr("'FOO'.lower()")).toBe("foo");
expect(evaluateExpr("'foo'.upper()")).toBe("FOO");
});
test("boolean", () => {
expect(evaluateExpr("True")).toBe(true);
expect(evaluateExpr("False")).toBe(false);
});
test("lists", () => {
expect(evaluateExpr("[]")).toEqual([]);
expect(evaluateExpr("[1]")).toEqual([1]);
expect(evaluateExpr("[1,2]")).toEqual([1, 2]);
expect(evaluateExpr("[1,False, None, 'foo']")).toEqual([1, false, null, "foo"]);
expect(evaluateExpr("[1,2 + 3]")).toEqual([1, 5]);
expect(evaluateExpr("[1,2, 3][1]")).toBe(2);
});
test("None", () => {
expect(evaluateExpr("None")).toBe(null);
});
test("Tuples", () => {
expect(evaluateExpr("()")).toEqual([]);
expect(evaluateExpr("(1,)")).toEqual([1]);
expect(evaluateExpr("(1,2)")).toEqual([1, 2]);
});
test("strings can be concatenated", () => {
expect(evaluateExpr('"foo" + "bar"')).toBe("foobar");
});
});
describe("number properties", () => {
test("number arithmetic", () => {
expect(evaluateExpr("1 + 2")).toBe(3);
expect(evaluateExpr("4 - 2")).toBe(2);
expect(evaluateExpr("4 * 2")).toBe(8);
expect(evaluateExpr("1.5 + 2")).toBe(3.5);
expect(evaluateExpr("1 + -1")).toBe(0);
expect(evaluateExpr("1 - 1")).toBe(0);
expect(evaluateExpr("1.5 - 2")).toBe(-0.5);
expect(evaluateExpr("0 * 5")).toBe(0);
expect(evaluateExpr("1 + 3 * 5")).toBe(16);
expect(evaluateExpr("42 * -2")).toBe(-84);
expect(evaluateExpr("1 / 2")).toBe(0.5);
expect(evaluateExpr("2 / 1")).toBe(2);
expect(evaluateExpr("42 % 5")).toBe(2);
expect(evaluateExpr("2 ** 3")).toBe(8);
expect(evaluateExpr("a + b", { a: 1, b: 41 })).toBe(42);
});
test("// operator", () => {
expect(evaluateExpr("1 // 2")).toBe(0);
expect(evaluateExpr("1 // -2")).toBe(-1);
expect(evaluateExpr("-1 // 2")).toBe(-1);
expect(evaluateExpr("6 // 2")).toBe(3);
});
});
describe("boolean properties", () => {
test("boolean arithmetic", () => {
expect(evaluateExpr("True and False")).toBe(false);
expect(evaluateExpr("True or False")).toBe(true);
expect(evaluateExpr("True and (False or True)")).toBe(true);
expect(evaluateExpr("not True")).toBe(false);
expect(evaluateExpr("not False")).toBe(true);
expect(evaluateExpr("not foo", { foo: false })).toBe(true);
expect(evaluateExpr("not None")).toBe(true);
expect(evaluateExpr("not []")).toBe(true);
expect(evaluateExpr("True == False or True == True")).toBe(true);
expect(evaluateExpr("False == True and False")).toBe(false);
});
test("get value from context", () => {
expect(evaluateExpr("foo == 'foo' or foo == 'bar'", { foo: "bar" })).toBe(true);
expect(evaluateExpr("foo == 'foo' and bar == 'bar'", { foo: "foo", bar: "bar" })).toBe(
true
);
});
test("should be lazy", () => {
// second clause should nameerror if evaluated
expect(() => evaluateExpr("foo == 'foo' and bar == 'bar'", { foo: "foo" })).toThrow();
expect(evaluateExpr("foo == 'foo' and bar == 'bar'", { foo: "bar" })).toBe(false);
expect(evaluateExpr("foo == 'foo' or bar == 'bar'", { foo: "foo" })).toBe(true);
});
test("should return the actual object", () => {
expect(evaluateExpr('"foo" or "bar"')).toBe("foo");
expect(evaluateExpr('None or "bar"')).toBe("bar");
expect(evaluateExpr("False or None")).toBe(null);
expect(evaluateExpr("0 or 1")).toBe(1);
expect(evaluateExpr("[] or False")).toBe(false);
});
});
describe("values from context", () => {
test("free variable", () => {
expect(evaluateExpr("a", { a: 3 })).toBe(3);
expect(evaluateExpr("a + b", { a: 3, b: 5 })).toBe(8);
expect(evaluateExpr("a", { a: true })).toBe(true);
expect(evaluateExpr("a", { a: false })).toBe(false);
expect(evaluateExpr("a", { a: null })).toBe(null);
expect(evaluateExpr("a", { a: "bar" })).toBe("bar");
expect(evaluateExpr("foo", { foo: [1, 2, 3] })).toEqual([1, 2, 3]);
});
test("special case for context: the eval context can be accessed as 'context'", () => {
expect(evaluateExpr("context.get('b', 54)", { b: 3 })).toBe(3);
expect(evaluateExpr("context.get('c', 54)", { b: 3 })).toBe(54);
});
test("true and false available in context", () => {
expect(evaluateExpr("true")).toBe(true);
expect(evaluateExpr("false")).toBe(false);
});
test("throw error if name is not defined", () => {
expect(() => evaluateExpr("a")).toThrow();
});
});
describe("comparisons", () => {
test("equality", () => {
expect(evaluateExpr("1 == 1")).toBe(true);
expect(evaluateExpr('"foo" == "foo"')).toBe(true);
expect(evaluateExpr('"foo" == "bar"')).toBe(false);
expect(evaluateExpr("1 == True")).toBe(true);
expect(evaluateExpr("True == 1")).toBe(true);
expect(evaluateExpr("1 == False")).toBe(false);
expect(evaluateExpr("False == 1")).toBe(false);
expect(evaluateExpr("0 == False")).toBe(true);
expect(evaluateExpr("False == 0")).toBe(true);
expect(evaluateExpr("None == None")).toBe(true);
expect(evaluateExpr("None == False")).toBe(false);
});
test("equality should work with free variables", () => {
expect(evaluateExpr("1 == a", { a: 1 })).toBe(true);
expect(evaluateExpr('foo == "bar"', { foo: "bar" })).toBe(true);
expect(evaluateExpr('foo == "bar"', { foo: "qux" })).toBe(false);
});
test("inequality", () => {
expect(evaluateExpr("1 != 2")).toBe(true);
expect(evaluateExpr('"foo" != "foo"')).toBe(false);
expect(evaluateExpr('"foo" != "bar"')).toBe(true);
});
test("inequality should work with free variables", () => {
expect(evaluateExpr("1 != a", { a: 42 })).toBe(true);
expect(evaluateExpr('foo != "bar"', { foo: "bar" })).toBe(false);
expect(evaluateExpr('foo != "bar"', { foo: "qux" })).toBe(true);
expect(evaluateExpr("foo != bar", { foo: "qux", bar: "quux" })).toBe(true);
});
test("should accept deprecated form", () => {
expect(evaluateExpr("1 <> 2")).toBe(true);
expect(evaluateExpr('"foo" <> "foo"')).toBe(false);
expect(evaluateExpr('"foo" <> "bar"')).toBe(true);
});
test("comparing numbers", () => {
expect(evaluateExpr("3 < 5")).toBe(true);
expect(evaluateExpr("3 > 5")).toBe(false);
expect(evaluateExpr("5 >= 3")).toBe(true);
expect(evaluateExpr("3 >= 3")).toBe(true);
expect(evaluateExpr("3 <= 5")).toBe(true);
expect(evaluateExpr("5 <= 3")).toBe(false);
});
test("should support comparison chains", () => {
expect(evaluateExpr("1 < 3 < 5")).toBe(true);
expect(evaluateExpr("5 > 3 > 1")).toBe(true);
expect(evaluateExpr("1 < 3 > 2 == 2 > -2")).toBe(true);
expect(evaluateExpr("1 < 2 < 3 < 4 < 5 < 6")).toBe(true);
});
test("should compare strings", () => {
expect(evaluateExpr("date >= current", { date: "2010-06-08", current: "2010-06-05" })).toBe(
true
);
expect(evaluateExpr('state >= "cancel"', { state: "cancel" })).toBe(true);
expect(evaluateExpr('state >= "cancel"', { state: "open" })).toBe(true);
});
test("mixed types comparisons", () => {
expect(evaluateExpr("None < 42")).toBe(true);
expect(evaluateExpr("None > 42")).toBe(false);
expect(evaluateExpr("42 > None")).toBe(true);
expect(evaluateExpr("None < False")).toBe(true);
expect(evaluateExpr("None < True")).toBe(true);
expect(evaluateExpr("False > None")).toBe(true);
expect(evaluateExpr("True > None")).toBe(true);
expect(evaluateExpr("None > False")).toBe(false);
expect(evaluateExpr("None > True")).toBe(false);
expect(evaluateExpr("0 > True")).toBe(false);
expect(evaluateExpr("0 < True")).toBe(true);
expect(evaluateExpr("1 <= True")).toBe(true);
expect(evaluateExpr('False < ""')).toBe(true);
expect(evaluateExpr('"" > False')).toBe(true);
expect(evaluateExpr('False > ""')).toBe(false);
expect(evaluateExpr('0 < ""')).toBe(true);
expect(evaluateExpr('"" > 0')).toBe(true);
expect(evaluateExpr('0 > ""')).toBe(false);
expect(evaluateExpr("3 < True")).toBe(false);
expect(evaluateExpr("3 > True")).toBe(true);
expect(evaluateExpr("{} > None")).toBe(true);
expect(evaluateExpr("{} < None")).toBe(false);
expect(evaluateExpr("{} > False")).toBe(true);
expect(evaluateExpr("{} < False")).toBe(false);
expect(evaluateExpr("3 < 'foo'")).toBe(true);
expect(evaluateExpr("'foo' < 4444")).toBe(false);
expect(evaluateExpr("{} < []")).toBe(true);
});
});
describe("containment", () => {
test("in tuples", () => {
expect(evaluateExpr("'bar' in ('foo', 'bar')")).toBe(true);
expect(evaluateExpr("'bar' in ('foo', 'qux')")).toBe(false);
expect(evaluateExpr("1 in (1,2,3,4)")).toBe(true);
expect(evaluateExpr("1 in (2,3,4)")).toBe(false);
expect(evaluateExpr("'url' in ('url',)")).toBe(true);
expect(evaluateExpr("'ur' in ('url',)")).toBe(false);
expect(evaluateExpr("'url' in ('url', 'foo', 'bar')")).toBe(true);
});
test("in strings", () => {
expect(evaluateExpr("'bar' in 'bar'")).toBe(true);
expect(evaluateExpr("'bar' in 'foobar'")).toBe(true);
expect(evaluateExpr("'bar' in 'fooqux'")).toBe(false);
});
test("in lists", () => {
expect(evaluateExpr("'bar' in ['foo', 'bar']")).toBe(true);
expect(evaluateExpr("'bar' in ['foo', 'qux']")).toBe(false);
expect(evaluateExpr("3 in [1,2,3]")).toBe(true);
expect(evaluateExpr("None in [1,'foo',None]")).toBe(true);
expect(evaluateExpr("not a in b", { a: 3, b: [1, 2, 4, 8] })).toBe(true);
});
test("not in", () => {
expect(evaluateExpr("1 not in (2,3,4)")).toBe(true);
expect(evaluateExpr('"ur" not in ("url",)')).toBe(true);
expect(evaluateExpr("-2 not in (1,2,3)")).toBe(true);
expect(evaluateExpr("-2 not in (1,-2,3)")).toBe(false);
});
});
describe("conversions", () => {
test("to bool", () => {
expect(evaluateExpr("bool('')")).toBe(false);
expect(evaluateExpr("bool('foo')")).toBe(true);
expect(evaluateExpr("bool(date_deadline)", { date_deadline: "2008" })).toBe(true);
expect(evaluateExpr("bool(s)", { s: "" })).toBe(false);
});
});
describe("callables", () => {
test("should not call function from context", () => {
expect(() => evaluateExpr("foo()", { foo: () => 3 })).toThrow();
expect(() => evaluateExpr("1 + foo()", { foo: () => 3 })).toThrow();
});
test("min/max", () => {
expect(evaluateExpr("max(3, 5)")).toBe(5);
expect(evaluateExpr("min(3, 5, 2, 7)")).toBe(2);
});
});
describe("dicts", () => {
test("dict", () => {
expect(evaluateExpr("{}")).toEqual({});
expect(evaluateExpr("{'foo': 1 + 2}")).toEqual({ foo: 3 });
expect(evaluateExpr("{'foo': 1, 'bar': 4}")).toEqual({ foo: 1, bar: 4 });
});
test("lookup and definition", () => {
expect(evaluateExpr("{'a': 1}['a']")).toBe(1);
expect(evaluateExpr("{1: 2}[1]")).toBe(2);
});
test("can get values with get method", () => {
expect(evaluateExpr("{'a': 1}.get('a')")).toBe(1);
expect(evaluateExpr("{'a': 1}.get('b')")).toBe(null);
expect(evaluateExpr("{'a': 1}.get('b', 54)")).toBe(54);
});
test("can get values from values 'context'", () => {
expect(evaluateExpr("context.get('a')", { context: { a: 123 } })).toBe(123);
const values = { context: { a: { b: { c: 321 } } } };
expect(evaluateExpr("context.get('a').b.c", values)).toBe(321);
expect(evaluateExpr("context.get('a', {'e': 5}).b.c", values)).toBe(321);
expect(evaluateExpr("context.get('d', 3)", values)).toBe(3);
expect(evaluateExpr("context.get('d', {'e': 5})['e']", values)).toBe(5);
});
test("can check if a key is in the 'context'", () => {
expect(evaluateExpr("'a' in context", { context: { a: 123 } })).toBe(true);
expect(evaluateExpr("'a' in context", { context: { b: 123 } })).toBe(false);
expect(evaluateExpr("'a' not in context", { context: { a: 123 } })).toBe(false);
expect(evaluateExpr("'a' not in context", { context: { b: 123 } })).toBe(true);
});
});
describe("objects", () => {
test("can read values from object", () => {
expect(evaluateExpr("obj.a", { obj: { a: 123 } })).toBe(123);
expect(evaluateExpr("obj.a.b.c", { obj: { a: { b: { c: 321 } } } })).toBe(321);
});
test("cannot call function in object", () => {
expect(() => evaluateExpr("obj.f(3)", { obj: { f: (n) => n + 1 } })).toThrow();
});
});
describe("if expressions", () => {
test("simple if expressions", () => {
expect(evaluateExpr("1 if True else 2")).toBe(1);
expect(evaluateExpr("1 if 3 < 2 else 'greater'")).toBe("greater");
});
test("only evaluate proper branch", () => {
// will throw if evaluate wrong branch => name error
expect(evaluateExpr("1 if True else boom")).toBe(1);
expect(evaluateExpr("boom if False else 222")).toBe(222);
});
});
describe("miscellaneous expressions", () => {
test("tuple in list", () => {
expect(evaluateExpr("[(1 + 2,'foo', True)]")).toEqual([[3, "foo", true]]);
});
});
describe("evaluate to boolean", () => {
test("simple expression", () => {
expect(evaluateBooleanExpr("12")).toBe(true);
expect(evaluateBooleanExpr("0")).toBe(false);
expect(evaluateBooleanExpr("0 + 3 - 1")).toBe(true);
expect(evaluateBooleanExpr("0 + 3 - 1 - 2")).toBe(false);
expect(evaluateBooleanExpr('"foo"')).toBe(true);
expect(evaluateBooleanExpr("[1]")).toBe(true);
expect(evaluateBooleanExpr("[]")).toBe(false);
});
test("use contextual values", () => {
expect(evaluateBooleanExpr("a", { a: 12 })).toBe(true);
expect(evaluateBooleanExpr("a", { a: 0 })).toBe(false);
expect(evaluateBooleanExpr("0 + 3 - a", { a: 1 })).toBe(true);
expect(evaluateBooleanExpr("0 + 3 - a - 2", { a: 1 })).toBe(false);
expect(evaluateBooleanExpr("0 + 3 - a - b", { a: 1, b: 2 })).toBe(false);
expect(evaluateBooleanExpr("a", { a: "foo" })).toBe(true);
expect(evaluateBooleanExpr("a", { a: [1] })).toBe(true);
expect(evaluateBooleanExpr("a", { a: [] })).toBe(false);
});
test("throw if has missing value", () => {
expect(() => evaluateBooleanExpr("a", { b: 0 })).toThrow();
expect(evaluateBooleanExpr("1 or a")).toBe(true); // do not throw (lazy value)
expect(() => evaluateBooleanExpr("0 or a")).toThrow();
expect(() => evaluateBooleanExpr("a or b", { b: true })).toThrow();
expect(() => evaluateBooleanExpr("a and b", { b: true })).toThrow();
expect(() => evaluateBooleanExpr("a()")).toThrow();
expect(() => evaluateBooleanExpr("a[0]")).toThrow();
expect(() => evaluateBooleanExpr("a.b")).toThrow();
expect(() => evaluateBooleanExpr("0 + 3 - a", { b: 1 })).toThrow();
expect(() => evaluateBooleanExpr("0 + 3 - a - 2", { b: 1 })).toThrow();
expect(() => evaluateBooleanExpr("0 + 3 - a - b", { b: 2 })).toThrow();
});
});
describe("sets", () => {
test("static set", () => {
expect(evaluateExpr("set()")).toEqual(new Set());
expect(evaluateExpr("set([])")).toEqual(new Set([]));
expect(evaluateExpr("set([0])")).toEqual(new Set([0]));
expect(evaluateExpr("set([1])")).toEqual(new Set([1]));
expect(evaluateExpr("set([0, 0])")).toEqual(new Set([0]));
expect(evaluateExpr("set([0, 1])")).toEqual(new Set([0, 1]));
expect(evaluateExpr("set([1, 1])")).toEqual(new Set([1]));
expect(evaluateExpr("set('')")).toEqual(new Set());
expect(evaluateExpr("set('a')")).toEqual(new Set(["a"]));
expect(evaluateExpr("set('ab')")).toEqual(new Set(["a", "b"]));
expect(evaluateExpr("set({})")).toEqual(new Set());
expect(evaluateExpr("set({ 'a': 1 })")).toEqual(new Set(["a"]));
expect(evaluateExpr("set({ '': 1, 'a': 1 })")).toEqual(new Set(["", "a"]));
expect(() => evaluateExpr("set(0)")).toThrow();
expect(() => evaluateExpr("set(1)")).toThrow();
expect(() => evaluateExpr("set(None)")).toThrow();
expect(() => evaluateExpr("set(false)")).toThrow();
expect(() => evaluateExpr("set(true)")).toThrow();
expect(() => evaluateExpr("set(1, 2)")).toThrow();
expect(() => evaluateExpr("set(expr)", { expr: undefined })).toThrow();
expect(() => evaluateExpr("set(expr)", { expr: null })).toThrow();
expect(() => evaluateExpr("set([], [])")).toThrow(); // valid but not supported by py_js
expect(() => evaluateExpr("set({ 'a' })")).toThrow(); // valid but not supported by py_js
});
test("set intersection", () => {
expect(evaluateExpr("set([1,2,3]).intersection()")).toEqual(new Set([1, 2, 3]));
expect(evaluateExpr("set([1,2,3]).intersection(set([2,3]))")).toEqual(new Set([2, 3]));
expect(evaluateExpr("set([1,2,3]).intersection([2,3])")).toEqual(new Set([2, 3]));
expect(evaluateExpr("set([1,2,3]).intersection(r)", { r: [2, 3] })).toEqual(
new Set([2, 3])
);
expect(evaluateExpr("r.intersection([2,3])", { r: new Set([1, 2, 3, 2]) })).toEqual(
new Set([2, 3])
);
expect(evaluateExpr("set(foo_ids).intersection([2,3])", { foo_ids: [1, 2] })).toEqual(
new Set([2])
);
expect(evaluateExpr("set(foo_ids).intersection([2,3])", { foo_ids: [1] })).toEqual(
new Set()
);
expect(evaluateExpr("set([foo_id]).intersection([2,3])", { foo_id: 1 })).toEqual(new Set());
expect(evaluateExpr("set([foo_id]).intersection([2,3])", { foo_id: 2 })).toEqual(
new Set([2])
);
expect(() => evaluateExpr("set([]).intersection([], [])")).toThrow(); // valid but not supported by py_js
expect(() => evaluateExpr("set([]).intersection([], [], [])")).toThrow(); // valid but not supported by py_js
});
test("set difference", () => {
expect(evaluateExpr("set([1,2,3]).difference()")).toEqual(new Set([1, 2, 3]));
expect(evaluateExpr("set([1,2,3]).difference(set([2,3]))")).toEqual(new Set([1]));
expect(evaluateExpr("set([1,2,3]).difference([2,3])")).toEqual(new Set([1]));
expect(evaluateExpr("set([1,2,3]).difference(r)", { r: [2, 3] })).toEqual(new Set([1]));
expect(evaluateExpr("r.difference([2,3])", { r: new Set([1, 2, 3, 2, 4]) })).toEqual(
new Set([1, 4])
);
expect(evaluateExpr("set(foo_ids).difference([2,3])", { foo_ids: [1, 2] })).toEqual(
new Set([1])
);
expect(evaluateExpr("set(foo_ids).difference([2,3])", { foo_ids: [1] })).toEqual(
new Set([1])
);
expect(evaluateExpr("set([foo_id]).difference([2,3])", { foo_id: 1 })).toEqual(
new Set([1])
);
expect(evaluateExpr("set([foo_id]).difference([2,3])", { foo_id: 2 })).toEqual(new Set());
expect(() => evaluateExpr("set([]).difference([], [])")).toThrow(); // valid but not supported by py_js
expect(() => evaluateExpr("set([]).difference([], [], [])")).toThrow(); // valid but not supported by py_js
});
test("set union", () => {
expect(evaluateExpr("set([1,2,3]).union()")).toEqual(new Set([1, 2, 3]));
expect(evaluateExpr("set([1,2,3]).union(set([2,3,4]))")).toEqual(new Set([1, 2, 3, 4]));
expect(evaluateExpr("set([1,2,3]).union([2,4])")).toEqual(new Set([1, 2, 3, 4]));
expect(evaluateExpr("set([1,2,3]).union(r)", { r: [2, 4] })).toEqual(new Set([1, 2, 3, 4]));
expect(evaluateExpr("r.union([2,3])", { r: new Set([1, 2, 2, 4]) })).toEqual(
new Set([1, 2, 4, 3])
);
expect(evaluateExpr("set(foo_ids).union([2,3])", { foo_ids: [1, 2] })).toEqual(
new Set([1, 2, 3])
);
expect(evaluateExpr("set(foo_ids).union([2,3])", { foo_ids: [1] })).toEqual(
new Set([1, 2, 3])
);
expect(evaluateExpr("set([foo_id]).union([2,3])", { foo_id: 1 })).toEqual(
new Set([1, 2, 3])
);
expect(evaluateExpr("set([foo_id]).union([2,3])", { foo_id: 2 })).toEqual(new Set([2, 3]));
expect(() => evaluateExpr("set([]).union([], [])")).toThrow(); // valid but not supported by py_js
expect(() => evaluateExpr("set([]).union([], [], [])")).toThrow(); // valid but not supported by py_js
});
});

View file

@ -0,0 +1,354 @@
import { describe, expect, test } from "@odoo/hoot";
import { parseExpr } from "@web/core/py_js/py";
describe.current.tags("headless");
test("can parse basic elements", () => {
expect(parseExpr("1")).toEqual({ type: 0 /* Number */, value: 1 });
expect(parseExpr('"foo"')).toEqual({ type: 1 /* String */, value: "foo" });
expect(parseExpr("foo")).toEqual({ type: 5 /* Name */, value: "foo" });
expect(parseExpr("True")).toEqual({ type: 2 /* Boolean */, value: true });
expect(parseExpr("False")).toEqual({ type: 2 /* Boolean */, value: false });
expect(parseExpr("None")).toEqual({ type: 3 /* None */ });
});
test("cannot parse empty string", () => {
expect(() => parseExpr("")).toThrow(/Error: Missing token/);
});
test("can parse unary operator -", () => {
expect(parseExpr("-1")).toEqual({
type: 6 /* UnaryOperator */,
op: "-",
right: { type: 0 /* Number */, value: 1 },
});
expect(parseExpr("-foo")).toEqual({
type: 6 /* UnaryOperator */,
op: "-",
right: { type: 5 /* Name */, value: "foo" },
});
expect(parseExpr("not True")).toEqual({
type: 6 /* UnaryOperator */,
op: "not",
right: { type: 2 /* Boolean */, value: true },
});
});
test("can parse parenthesis", () => {
expect(parseExpr("(1 + 2)")).toEqual({
type: 7 /* BinaryOperator */,
op: "+",
left: { type: 0 /* Number */, value: 1 },
right: { type: 0 /* Number */, value: 2 },
});
});
test("can parse binary operators", () => {
expect(parseExpr("1 < 2")).toEqual({
type: 7 /* BinaryOperator */,
op: "<",
left: { type: 0 /* Number */, value: 1 },
right: { type: 0 /* Number */, value: 2 },
});
expect(parseExpr('a + "foo"')).toEqual({
type: 7 /* BinaryOperator */,
op: "+",
left: { type: 5 /* Name */, value: "a" },
right: { type: 1 /* String */, value: "foo" },
});
});
test("can parse boolean operators", () => {
expect(parseExpr('True and "foo"')).toEqual({
type: 14 /* BooleanOperator */,
op: "and",
left: { type: 2 /* Boolean */, value: true },
right: { type: 1 /* String */, value: "foo" },
});
expect(parseExpr('True or "foo"')).toEqual({
type: 14 /* BooleanOperator */,
op: "or",
left: { type: 2 /* Boolean */, value: true },
right: { type: 1 /* String */, value: "foo" },
});
});
test("expression with == and or", () => {
expect(parseExpr("False == True and False")).toEqual({
type: 14 /* BooleanOperator */,
op: "and",
left: {
type: 7 /* BinaryOperator */,
op: "==",
left: { type: 2 /* Boolean */, value: false },
right: { type: 2 /* Boolean */, value: true },
},
right: { type: 2 /* Boolean */, value: false },
});
});
test("expression with + and ==", () => {
expect(parseExpr("1 + 2 == 3")).toEqual({
type: 7 /* BinaryOperator */,
op: "==",
left: {
type: 7 /* BinaryOperator */,
op: "+",
left: { type: 0 /* Number */, value: 1 },
right: { type: 0 /* Number */, value: 2 },
},
right: { type: 0 /* Number */, value: 3 },
});
});
test("can parse chained comparisons", () => {
expect(parseExpr("1 < 2 <= 3")).toEqual({
type: 14 /* BooleanOperator */,
op: "and",
left: {
type: 7 /* BinaryOperator */,
op: "<",
left: { type: 0 /* Number */, value: 1 },
right: { type: 0 /* Number */, value: 2 },
},
right: {
type: 7 /* BinaryOperator */,
op: "<=",
left: { type: 0 /* Number */, value: 2 },
right: { type: 0 /* Number */, value: 3 },
},
});
expect(parseExpr("1 < 2 <= 3 > 33")).toEqual({
type: 14 /* BooleanOperator */,
op: "and",
left: {
type: 14 /* BooleanOperator */,
op: "and",
left: {
type: 7 /* BinaryOperator */,
op: "<",
left: { type: 0 /* Number */, value: 1 },
right: { type: 0 /* Number */, value: 2 },
},
right: {
type: 7 /* BinaryOperator */,
op: "<=",
left: { type: 0 /* Number */, value: 2 },
right: { type: 0 /* Number */, value: 3 },
},
},
right: {
type: 7 /* BinaryOperator */,
op: ">",
left: { type: 0 /* Number */, value: 3 },
right: { type: 0 /* Number */, value: 33 },
},
});
});
test("can parse lists", () => {
expect(parseExpr("[]")).toEqual({
type: 4 /* List */,
value: [],
});
expect(parseExpr("[1]")).toEqual({
type: 4 /* List */,
value: [{ type: 0 /* Number */, value: 1 }],
});
expect(parseExpr("[1,]")).toEqual({
type: 4 /* List */,
value: [{ type: 0 /* Number */, value: 1 }],
});
expect(parseExpr("[1, 4]")).toEqual({
type: 4 /* List */,
value: [
{ type: 0 /* Number */, value: 1 },
{ type: 0 /* Number */, value: 4 },
],
});
expect(() => parseExpr("[1 1]")).toThrow();
});
test("can parse lists lookup", () => {
expect(parseExpr("[1,2][1]")).toEqual({
type: 12 /* Lookup */,
target: {
type: 4 /* List */,
value: [
{ type: 0 /* Number */, value: 1 },
{ type: 0 /* Number */, value: 2 },
],
},
key: { type: 0 /* Number */, value: 1 },
});
});
test("can parse tuples", () => {
expect(parseExpr("()")).toEqual({
type: 10 /* Tuple */,
value: [],
});
expect(parseExpr("(1,)")).toEqual({
type: 10 /* Tuple */,
value: [{ type: 0 /* Number */, value: 1 }],
});
expect(parseExpr("(1,4)")).toEqual({
type: 10 /* Tuple */,
value: [
{ type: 0 /* Number */, value: 1 },
{ type: 0 /* Number */, value: 4 },
],
});
expect(() => parseExpr("(1 1)")).toThrow();
});
test("can parse dictionary", () => {
expect(parseExpr("{}")).toEqual({
type: 11 /* Dictionary */,
value: {},
});
expect(parseExpr("{'foo': 1}")).toEqual({
type: 11 /* Dictionary */,
value: { foo: { type: 0 /* Number */, value: 1 } },
});
expect(parseExpr("{'foo': 1, 'bar': 3}")).toEqual({
type: 11 /* Dictionary */,
value: {
foo: { type: 0 /* Number */, value: 1 },
bar: { type: 0 /* Number */, value: 3 },
},
});
expect(parseExpr("{1: 2}")).toEqual({
type: 11 /* Dictionary */,
value: { 1: { type: 0 /* Number */, value: 2 } },
});
});
test("can parse dictionary lookup", () => {
expect(parseExpr("{}['a']")).toEqual({
type: 12 /* Lookup */,
target: { type: 11 /* Dictionary */, value: {} },
key: { type: 1 /* String */, value: "a" },
});
});
test("can parse assignment", () => {
expect(parseExpr("a=1")).toEqual({
type: 9 /* Assignment */,
name: { type: 5 /* Name */, value: "a" },
value: { type: 0 /* Number */, value: 1 },
});
});
test("can parse function calls", () => {
expect(parseExpr("f()")).toEqual({
type: 8 /* FunctionCall */,
fn: { type: 5 /* Name */, value: "f" },
args: [],
kwargs: {},
});
expect(parseExpr("f() + 2")).toEqual({
type: 7 /* BinaryOperator */,
op: "+",
left: {
type: 8 /* FunctionCall */,
fn: { type: 5 /* Name */, value: "f" },
args: [],
kwargs: {},
},
right: { type: 0 /* Number */, value: 2 },
});
expect(parseExpr("f(1)")).toEqual({
type: 8 /* FunctionCall */,
fn: { type: 5 /* Name */, value: "f" },
args: [{ type: 0 /* Number */, value: 1 }],
kwargs: {},
});
expect(parseExpr("f(1, 2)")).toEqual({
type: 8 /* FunctionCall */,
fn: { type: 5 /* Name */, value: "f" },
args: [
{ type: 0 /* Number */, value: 1 },
{ type: 0 /* Number */, value: 2 },
],
kwargs: {},
});
});
test("can parse function calls with kwargs", () => {
expect(parseExpr("f(a = 1)")).toEqual({
type: 8 /* FunctionCall */,
fn: { type: 5 /* Name */, value: "f" },
args: [],
kwargs: { a: { type: 0 /* Number */, value: 1 } },
});
expect(parseExpr("f(3, a = 1)")).toEqual({
type: 8 /* FunctionCall */,
fn: { type: 5 /* Name */, value: "f" },
args: [{ type: 0 /* Number */, value: 3 }],
kwargs: { a: { type: 0 /* Number */, value: 1 } },
});
});
test("can parse not a in b", () => {
expect(parseExpr("not a in b")).toEqual({
type: 6 /* UnaryOperator */,
op: "not",
right: {
type: 7 /* BinaryOperator */,
op: "in",
left: { type: 5 /* Name */, value: "a" },
right: { type: 5 /* Name */, value: "b" },
},
});
expect(parseExpr("a.b.c")).toEqual({
type: 15 /* ObjLookup */,
obj: {
type: 15 /* ObjLookup */,
obj: { type: 5 /* Name */, value: "a" },
key: "b",
},
key: "c",
});
});
test("can parse if statement", () => {
expect(parseExpr("1 if True else 2")).toEqual({
type: 13 /* If */,
condition: { type: 2 /* Boolean */, value: true },
ifTrue: { type: 0 /* Number */, value: 1 },
ifFalse: { type: 0 /* Number */, value: 2 },
});
expect(parseExpr("1 + 1 if True else 2")).toEqual({
type: 13 /* If */,
condition: { type: 2 /* Boolean */, value: true },
ifTrue: {
type: 7 /* BinaryOperator */,
op: "+",
left: { type: 0 /* Number */, value: 1 },
right: { type: 0 /* Number */, value: 1 },
},
ifFalse: { type: 0 /* Number */, value: 2 },
});
});
test("tuple in list", () => {
expect(parseExpr("[(1,2)]")).toEqual({
type: 4 /* List */,
value: [
{
type: 10 /* Tuple */,
value: [
{ type: 0 /* Number */, value: 1 },
{ type: 0 /* Number */, value: 2 },
],
},
],
});
});
test("cannot parse []a", () => {
expect(() => parseExpr("[]a")).toThrow(/Error: Token\(s\) unused/);
expect(() => parseExpr("[]a b")).toThrow(/Error: Token\(s\) unused/);
});

View file

@ -0,0 +1,140 @@
import { describe, expect, test } from "@odoo/hoot";
import { evaluateExpr } from "@web/core/py_js/py";
import { PyTimeDelta } from "@web/core/py_js/py_date";
const expectDelta = (expr, res) => {
const timedelta = evaluateExpr(expr, { td: PyTimeDelta });
expect(`${timedelta.days}, ${timedelta.seconds}, ${timedelta.microseconds}`).toBe(res);
};
const expectEquality = (expr1, expr2, ctx) => {
const equality = `${expr1} == ${expr2}`;
expect(evaluateExpr(equality, Object.assign({ td: PyTimeDelta }, ctx))).toBe(true, {
message: `evaluating ${equality}`,
});
};
describe.current.tags("headless");
test("create", () => {
expectDelta("td(weeks=1)", "7, 0, 0");
expectDelta("td(days=1)", "1, 0, 0");
expectDelta("td(hours=1)", "0, 3600, 0");
expectDelta("td(minutes=1)", "0, 60, 0");
expectDelta("td(seconds=1)", "0, 1, 0");
expectDelta("td(milliseconds=1)", "0, 0, 1000");
expectDelta("td(microseconds=1)", "0, 0, 1");
expectDelta("td(days=-1.25)", "-2, 64800, 0");
expectDelta("td(seconds=129600.4)", "1, 43200, 400000");
expectDelta("td(hours=24.5,milliseconds=1400)", "1, 1801, 400000");
expectEquality(
"td()",
"td(weeks=0, days=0, hours=0, minutes=0, seconds=0, milliseconds=0, microseconds=0)"
);
expectEquality("td(1)", "td(days=1)");
expectEquality("td(0, 1)", "td(seconds=1)");
expectEquality("td(0, 0, 1)", "td(microseconds=1)");
expectEquality("td(weeks=1)", "td(days=7)");
expectEquality("td(days=1)", "td(hours=24)");
expectEquality("td(hours=1)", "td(minutes=60)");
expectEquality("td(minutes=1)", "td(seconds=60)");
expectEquality("td(seconds=1)", "td(milliseconds=1000)");
expectEquality("td(milliseconds=1)", "td(microseconds=1000)");
expectEquality("td(weeks=1.0/7)", "td(days=1)");
expectEquality("td(days=1.0/24)", "td(hours=1)");
expectEquality("td(hours=1.0/60)", "td(minutes=1)");
expectEquality("td(minutes=1.0/60)", "td(seconds=1)");
expectEquality("td(seconds=0.001)", "td(milliseconds=1)");
expectEquality("td(milliseconds=0.001)", "td(microseconds=1)");
});
test("massive normalization", () => {
expect.assertions(3);
const td = PyTimeDelta.create({ microseconds: -1 });
expect(td.days).toBe(-1);
expect(td.seconds).toBe(24 * 3600 - 1);
expect(td.microseconds).toBe(999999);
});
test("attributes", () => {
expect.assertions(3);
const ctx = { td: PyTimeDelta };
expect(evaluateExpr("td(1, 7, 31).days", ctx)).toBe(1);
expect(evaluateExpr("td(1, 7, 31).seconds", ctx)).toBe(7);
expect(evaluateExpr("td(1, 7, 31).microseconds", ctx)).toBe(31);
});
test("basic operations: +, -, *, //", () => {
expect.assertions(28);
const ctx = {
a: new PyTimeDelta(7, 0, 0),
b: new PyTimeDelta(0, 60, 0),
c: new PyTimeDelta(0, 0, 1000),
};
expectEquality("a+b+c", "td(7, 60, 1000)", ctx);
expectEquality("a-b", "td(6, 24*3600 - 60)", ctx);
expectEquality("-a", "td(-7)", ctx);
expectEquality("+a", "td(7)", ctx);
expectEquality("-b", "td(-1, 24*3600 - 60)", ctx);
expectEquality("-c", "td(-1, 24*3600 - 1, 999000)", ctx);
expectEquality("td(6, 24*3600)", "a", ctx);
expectEquality("td(0, 0, 60*1000000)", "b", ctx);
expectEquality("a*10", "td(70)", ctx);
expectEquality("a*10", "10*a", ctx);
// expectEquality('a*10L', '10*a', ctx);
expectEquality("b*10", "td(0, 600)", ctx);
expectEquality("10*b", "td(0, 600)", ctx);
// expectEquality('b*10L', 'td(0, 600)', ctx);
expectEquality("c*10", "td(0, 0, 10000)", ctx);
expectEquality("10*c", "td(0, 0, 10000)", ctx);
// expectEquality('c*10L', 'td(0, 0, 10000)', ctx);
expectEquality("a*-1", "-a", ctx);
expectEquality("b*-2", "-b-b", ctx);
expectEquality("c*-2", "-c+-c", ctx);
expectEquality("b*(60*24)", "(b*60)*24", ctx);
expectEquality("b*(60*24)", "(60*b)*24", ctx);
expectEquality("c*1000", "td(0, 1)", ctx);
expectEquality("1000*c", "td(0, 1)", ctx);
expectEquality("a//7", "td(1)", ctx);
expectEquality("b//10", "td(0, 6)", ctx);
expectEquality("c//1000", "td(0, 0, 1)", ctx);
expectEquality("a//10", "td(0, 7*24*360)", ctx);
expectEquality("a//3600000", "td(0, 0, 7*24*1000)", ctx);
expectEquality("td(999999999, 86399, 999999) - td(999999999, 86399, 999998)", "td(0, 0, 1)");
expectEquality("td(999999999, 1, 1) - td(999999999, 1, 0)", "td(0, 0, 1)");
});
test("total_seconds", () => {
expect.assertions(6);
const ctx = { td: PyTimeDelta };
expect(evaluateExpr("td(365).total_seconds()", ctx)).toBe(31536000);
expect(evaluateExpr("td(seconds=123456.789012).total_seconds()", ctx)).toBe(123456.789012);
expect(evaluateExpr("td(seconds=-123456.789012).total_seconds()", ctx)).toBe(-123456.789012);
expect(evaluateExpr("td(seconds=0.123456).total_seconds()", ctx)).toBe(0.123456);
expect(evaluateExpr("td().total_seconds()", ctx)).toBe(0);
expect(evaluateExpr("td(seconds=1000000).total_seconds()", ctx)).toBe(1e6);
});
test("bool", () => {
expect.assertions(5);
const ctx = { td: PyTimeDelta };
expect(evaluateExpr("bool(td(1))", ctx)).toBe(true);
expect(evaluateExpr("bool(td(0, 1))", ctx)).toBe(true);
expect(evaluateExpr("bool(td(0, 0, 1))", ctx)).toBe(true);
expect(evaluateExpr("bool(td(microseconds=1))", ctx)).toBe(true);
expect(evaluateExpr("bool(not td(0))", ctx)).toBe(true);
});

View file

@ -0,0 +1,114 @@
import { describe, expect, test } from "@odoo/hoot";
import { tokenize } from "@web/core/py_js/py";
describe.current.tags("headless");
test("can tokenize simple expressions with spaces", () => {
expect(tokenize("1")).toEqual([{ type: 0 /* Number */, value: 1 }]);
expect(tokenize(" 1")).toEqual([{ type: 0 /* Number */, value: 1 }]);
expect(tokenize(" 1 ")).toEqual([{ type: 0 /* Number */, value: 1 }]);
});
test("can tokenize numbers", () => {
/* Without exponent */
expect(tokenize("1")).toEqual([{ type: 0 /* Number */, value: 1 }]);
expect(tokenize("13")).toEqual([{ type: 0 /* Number */, value: 13 }]);
expect(tokenize("-1")).toEqual([
{ type: 2 /* Symbol */, value: "-" },
{ type: 0 /* Number */, value: 1 },
]);
/* With exponent */
expect(tokenize("1e2")).toEqual([{ type: 0 /* Number */, value: 100 }]);
expect(tokenize("13E+02")).toEqual([{ type: 0 /* Number */, value: 1300 }]);
expect(tokenize("15E-2")).toEqual([{ type: 0 /* Number */, value: 0.15 }]);
expect(tokenize("-30e+002")).toEqual([
{ type: 2 /* Symbol */, value: "-" },
{ type: 0 /* Number */, value: 3000 },
]);
});
test("can tokenize floats", () => {
/* Without exponent */
expect(tokenize("12.0")).toEqual([{ type: 0 /* Number */, value: 12 }]);
expect(tokenize("1.2")).toEqual([{ type: 0 /* Number */, value: 1.2 }]);
expect(tokenize(".42")).toEqual([{ type: 0 /* Number */, value: 0.42 }]);
expect(tokenize("12.")).toEqual([{ type: 0 /* Number */, value: 12 }]);
expect(tokenize("-1.23")).toEqual([
{ type: 2 /* Symbol */, value: "-" },
{ type: 0 /* Number */, value: 1.23 },
]);
/* With exponent */
expect(tokenize("1234e-3")).toEqual([{ type: 0 /* Number */, value: 1.234 }]);
expect(tokenize("1.23E-03")).toEqual([{ type: 0 /* Number */, value: 0.00123 }]);
expect(tokenize(".23e-3")).toEqual([{ type: 0 /* Number */, value: 0.00023 }]);
expect(tokenize("23.e-03")).toEqual([{ type: 0 /* Number */, value: 0.023 }]);
expect(tokenize("12.1E2")).toEqual([{ type: 0 /* Number */, value: 1210 }]);
expect(tokenize("1.23e+03")).toEqual([{ type: 0 /* Number */, value: 1230 }]);
expect(tokenize(".23e2")).toEqual([{ type: 0 /* Number */, value: 23 }]);
expect(tokenize("15.E+02")).toEqual([{ type: 0 /* Number */, value: 1500 }]);
expect(tokenize("-23E02")).toEqual([
{ type: 2 /* Symbol */, value: "-" },
{ type: 0 /* Number */, value: 2300 },
]);
});
test("can tokenize strings", () => {
expect(tokenize('"foo"')).toEqual([{ type: 1 /* String */, value: "foo" }]);
});
test("can tokenize bare names", () => {
expect(tokenize("foo")).toEqual([{ type: 3 /* Name */, value: "foo" }]);
});
test("can tokenize misc operators", () => {
expect(tokenize("in")).toEqual([{ type: 2 /* Symbol */, value: "in" }]);
expect(tokenize("not in")).toEqual([{ type: 2 /* Symbol */, value: "not in" }]);
expect(tokenize("3 ** 2")[1]).toEqual({ type: 2 /* Symbol */, value: "**" });
});
test("can tokenize constants", () => {
expect(tokenize("None")).toEqual([{ type: 4 /* Constant */, value: "None" }]);
expect(tokenize("True")).toEqual([{ type: 4 /* Constant */, value: "True" }]);
expect(tokenize("False")).toEqual([{ type: 4 /* Constant */, value: "False" }]);
});
test("can tokenize parenthesis", () => {
expect(tokenize("()")).toEqual([
{ type: 2 /* Symbol */, value: "(" },
{ type: 2 /* Symbol */, value: ")" },
]);
});
test("can tokenize function with kwargs", () => {
expect(tokenize('foo(bar=3, qux="4")')).toEqual([
{ type: 3 /* Name */, value: "foo" },
{ type: 2 /* Symbol */, value: "(" },
{ type: 3 /* Name */, value: "bar" },
{ type: 2 /* Symbol */, value: "=" },
{ type: 0 /* Number */, value: 3 },
{ type: 2 /* Symbol */, value: "," },
{ type: 3 /* Name */, value: "qux" },
{ type: 2 /* Symbol */, value: "=" },
{ type: 1 /* String */, value: "4" },
{ type: 2 /* Symbol */, value: ")" },
]);
});
test("can tokenize if statement", () => {
expect(tokenize("1 if True else 2")).toEqual([
{ type: 0 /* Number */, value: 1 },
{ type: 2 /* Symbol */, value: "if" },
{ type: 4 /* Constant */, value: "True" },
{ type: 2 /* Symbol */, value: "else" },
{ type: 0 /* Number */, value: 2 },
]);
});
test("sanity check: throw some errors", () => {
expect(() => tokenize("'asdf")).toThrow();
});

View file

@ -0,0 +1,206 @@
import { describe, expect, test } from "@odoo/hoot";
import { evaluateExpr, formatAST, parseExpr } from "@web/core/py_js/py";
import { PyDate, PyDateTime } from "@web/core/py_js/py_date";
import { toPyValue } from "@web/core/py_js/py_utils";
const checkAST = (expr, message = expr) => {
const ast = parseExpr(expr);
const str = formatAST(ast);
if (str !== expr) {
throw new Error(`mismatch: ${str} !== ${expr} (${message});`);
}
return true;
};
describe.current.tags("headless");
describe("formatAST", () => {
test("basic values", () => {
expect(checkAST("1", "number value")).toBe(true);
expect(checkAST("1.4", "float value")).toBe(true);
expect(checkAST("-12", "negative number value")).toBe(true);
expect(checkAST("True", "boolean")).toBe(true);
expect(checkAST(`"some string"`, "a string")).toBe(true);
expect(checkAST("None", "None")).toBe(true);
});
test("dictionary", () => {
expect(checkAST("{}", "empty dictionary")).toBe(true);
expect(checkAST(`{"a": 1}`, "dictionary with a single key")).toBe(true);
expect(checkAST(`d["a"]`, "get a value in a dictionary")).toBe(true);
});
test("list", () => {
expect(checkAST("[]", "empty list")).toBe(true);
expect(checkAST("[1]", "list with one value")).toBe(true);
expect(checkAST("[1, 2]", "list with two values")).toBe(true);
});
test("tuple", () => {
expect(checkAST("()", "empty tuple")).toBe(true);
expect(checkAST("(1, 2)", "basic tuple")).toBe(true);
});
test("simple arithmetic", () => {
expect(checkAST("1 + 2", "addition")).toBe(true);
expect(checkAST("+(1 + 2)", "other addition, prefix")).toBe(true);
expect(checkAST("1 - 2", "substraction")).toBe(true);
expect(checkAST("-1 - 2", "other substraction")).toBe(true);
expect(checkAST("-(1 + 2)", "other substraction")).toBe(true);
expect(checkAST("1 + 2 + 3", "addition of 3 integers")).toBe(true);
expect(checkAST("a + b", "addition of two variables")).toBe(true);
expect(checkAST("42 % 5", "modulo operator")).toBe(true);
expect(checkAST("a * 10", "multiplication")).toBe(true);
expect(checkAST("a ** 10", "**")).toBe(true);
expect(checkAST("~10", "bitwise not")).toBe(true);
expect(checkAST("~(10 + 3)", "bitwise not")).toBe(true);
expect(checkAST("a * (1 + 2)", "multiplication and addition")).toBe(true);
expect(checkAST("(a + b) * 43", "addition and multiplication")).toBe(true);
expect(checkAST("a // 10", "number division")).toBe(true);
});
test("boolean operators", () => {
expect(checkAST("True and False", "boolean operator")).toBe(true);
expect(checkAST("True or False", "boolean operator or")).toBe(true);
expect(checkAST("(True or False) and False", "boolean operators and and or")).toBe(true);
expect(checkAST("not False", "not prefix")).toBe(true);
expect(checkAST("not foo", "not prefix with variable")).toBe(true);
expect(checkAST("not a in b", "not prefix with expression")).toBe(true);
});
test("conditional expression", () => {
expect(checkAST("1 if a else 2")).toBe(true);
expect(checkAST("[] if a else 2")).toBe(true);
});
test("other operators", () => {
expect(checkAST("x == y", "== operator")).toBe(true);
expect(checkAST("x != y", "!= operator")).toBe(true);
expect(checkAST("x < y", "< operator")).toBe(true);
expect(checkAST("x is y", "is operator")).toBe(true);
expect(checkAST("x is not y", "is and not operator")).toBe(true);
expect(checkAST("x in y", "in operator")).toBe(true);
expect(checkAST("x not in y", "not in operator")).toBe(true);
});
test("equality", () => {
expect(checkAST("a == b", "simple equality")).toBe(true);
});
test("strftime", () => {
expect(checkAST(`time.strftime("%Y")`, "strftime with year")).toBe(true);
expect(checkAST(`time.strftime("%Y") + "-01-30"`, "strftime with year")).toBe(true);
expect(checkAST(`time.strftime("%Y-%m-%d %H:%M:%S")`, "strftime with year")).toBe(true);
});
test("context_today", () => {
expect(checkAST(`context_today().strftime("%Y-%m-%d")`, "context today call")).toBe(true);
});
test("function call", () => {
expect(checkAST("td()", "simple call")).toBe(true);
expect(checkAST("td(a, b, c)", "simple call with args")).toBe(true);
expect(checkAST("td(days = 1)", "simple call with kwargs")).toBe(true);
expect(checkAST("f(1, 2, days = 1)", "mixing args and kwargs")).toBe(true);
expect(checkAST("str(td(2))", "function call in function call")).toBe(true);
});
test("various expressions", () => {
expect(checkAST("(a - b).days", "substraction and .days")).toBe(true);
expect(checkAST("a + day == date(2002, 3, 3)")).toBe(true);
const expr = `[("type", "=", "in"), ("day", "<=", time.strftime("%Y-%m-%d")), ("day", ">", (context_today() - datetime.timedelta(days = 15)).strftime("%Y-%m-%d"))]`;
expect(checkAST(expr)).toBe(true);
});
test("escaping support", () => {
expect(evaluateExpr(String.raw`"\x61"`)).toBe("a", { message: "hex escapes" });
expect(evaluateExpr(String.raw`"\\abc"`)).toBe(String.raw`\abc`, {
message: "escaped backslash",
});
expect(checkAST(String.raw`"\\abc"`, "escaped backslash AST check")).toBe(true);
const a = String.raw`'foo\\abc"\''`;
const b = formatAST(parseExpr(formatAST(parseExpr(a))));
// Our repr uses JSON.stringify which always uses double quotes,
// whereas Python's repr is single-quote-biased: strings are repr'd
// using single quote delimiters *unless* they contain single quotes and
// no double quotes, then they're delimited with double quotes.
expect(b).toBe(String.raw`"foo\\abc\"'"`);
});
test("null value", () => {
expect(formatAST(toPyValue(null))).toBe("None");
});
});
describe("toPyValue", () => {
test("toPyValue a string", () => {
const ast = toPyValue("test");
expect(ast.type).toBe(1);
expect(ast.value).toBe("test");
expect(formatAST(ast)).toBe('"test"');
});
test("toPyValue a number", () => {
const ast = toPyValue(1);
expect(ast.type).toBe(0);
expect(ast.value).toBe(1);
expect(formatAST(ast)).toBe("1");
});
test("toPyValue a boolean", () => {
let ast = toPyValue(true);
expect(ast.type).toBe(2);
expect(ast.value).toBe(true);
expect(formatAST(ast)).toBe("True");
ast = toPyValue(false);
expect(ast.type).toBe(2);
expect(ast.value).toBe(false);
expect(formatAST(ast)).toBe("False");
});
test("toPyValue a object", () => {
const ast = toPyValue({ a: 1 });
expect(ast.type).toBe(11);
expect("a" in ast.value).toBe(true);
expect(["type", "value"].every((prop) => prop in ast.value.a)).toBe(true);
expect(ast.value.a.type).toBe(0);
expect(ast.value.a.value).toBe(1);
expect(formatAST(ast)).toBe('{"a": 1}');
});
test("toPyValue a date", () => {
const date = new Date(Date.UTC(2000, 0, 1));
const ast = toPyValue(date);
expect(ast.type).toBe(1);
const expectedValue = PyDateTime.convertDate(date);
expect(ast.value.isEqual(expectedValue)).toBe(true);
expect(formatAST(ast)).toBe(JSON.stringify(expectedValue));
});
test("toPyValue a dateime", () => {
const datetime = new Date(Date.UTC(2000, 0, 1, 1, 0, 0, 0));
const ast = toPyValue(datetime);
expect(ast.type).toBe(1);
const expectedValue = PyDateTime.convertDate(datetime);
expect(ast.value.isEqual(expectedValue)).toBe(true);
expect(formatAST(ast)).toBe(JSON.stringify(expectedValue));
});
test("toPyValue a PyDate", () => {
const value = new PyDate(2000, 1, 1);
const ast = toPyValue(value);
expect(ast.type).toBe(1);
expect(ast.value).toBe(value);
expect(formatAST(ast)).toBe(JSON.stringify(value));
});
test("toPyValue a PyDateTime", () => {
const value = new PyDateTime(2000, 1, 1, 1, 0, 0, 0);
const ast = toPyValue(value);
expect(ast.type).toBe(1);
expect(ast.value).toBe(value);
expect(formatAST(ast)).toBe(JSON.stringify(value));
});
});

View file

@ -0,0 +1,195 @@
import { describe, expect, test } from "@odoo/hoot";
import { EventBus, reactive } from "@odoo/owl";
import { Reactive, effect, withComputedProperties } from "@web/core/utils/reactive";
describe.current.tags("headless");
describe("class", () => {
test("callback registered without Reactive class constructor will not notify", async () => {
// This test exists to showcase why we need the Reactive class
const bus = new EventBus();
class MyReactiveClass {
constructor() {
this.counter = 0;
bus.addEventListener("change", () => this.counter++);
}
}
const obj = reactive(new MyReactiveClass(), () => {
expect.step(`counter: ${obj.counter}`);
});
obj.counter; // initial subscription to counter
obj.counter++;
expect.verifySteps(["counter: 1"]);
bus.trigger("change");
expect(obj.counter).toBe(2);
expect.verifySteps([
// The mutation in the event handler was missed by the reactivity, this is because
// the `this` in the event handler is captured during construction and is not reactive
]);
});
test("callback registered in Reactive class constructor will notify", async () => {
const bus = new EventBus();
class MyReactiveClass extends Reactive {
constructor() {
super();
this.counter = 0;
bus.addEventListener("change", () => this.counter++);
}
}
const obj = reactive(new MyReactiveClass(), () => {
expect.step(`counter: ${obj.counter}`);
});
obj.counter; // initial subscription to counter
obj.counter++;
expect.verifySteps(["counter: 1"]);
bus.trigger("change");
expect(obj.counter).toBe(2);
expect.verifySteps(["counter: 2"]);
});
});
describe("effect", () => {
test("effect runs once immediately", async () => {
const state = reactive({ counter: 0 });
expect.verifySteps([]);
effect(
(state) => {
expect.step(`counter: ${state.counter}`);
},
[state]
);
expect.verifySteps(["counter: 0"]);
});
test("effect runs when reactive deps change", async () => {
const state = reactive({ counter: 0 });
expect.verifySteps([]);
effect(
(state) => {
expect.step(`counter: ${state.counter}`);
},
[state]
);
// effect runs immediately
expect.verifySteps(["counter: 0"]);
state.counter++;
// first mutation runs the effect
expect.verifySteps(["counter: 1"]);
state.counter++;
// subsequent mutations run the effect
expect.verifySteps(["counter: 2"]);
});
test("Original reactive callback is not subscribed to keys observed by effect", async () => {
let reactiveCallCount = 0;
const state = reactive(
{
counter: 0,
},
() => reactiveCallCount++
);
expect.verifySteps([]);
expect(reactiveCallCount).toBe(0);
effect(
(state) => {
expect.step(`counter: ${state.counter}`);
},
[state]
);
expect.verifySteps(["counter: 0"]);
expect(reactiveCallCount).toBe(0, {
message: "did not call the original reactive's callback",
});
state.counter = 1;
expect.verifySteps(["counter: 1"]);
expect(reactiveCallCount).toBe(0, {
message: "did not call the original reactive's callback",
});
state.counter; // subscribe the original reactive
state.counter = 2;
expect.verifySteps(["counter: 2"]);
expect(reactiveCallCount).toBe(1, {
message: "the original callback was called because it is subscribed independently",
});
});
test("mutating keys not observed by the effect doesn't cause it to run", async () => {
const state = reactive({ counter: 0, unobserved: 0 });
effect(
(state) => {
expect.step(`counter: ${state.counter}`);
},
[state]
);
expect.verifySteps(["counter: 0"]);
state.counter = 1;
expect.verifySteps(["counter: 1"]);
state.unobserved = 1;
expect.verifySteps([]);
});
});
describe("withComputedProperties", () => {
test("computed properties are set immediately", async () => {
const source = reactive({ counter: 1 });
const derived = withComputedProperties(reactive({}), [source], {
doubleCounter(source) {
return source.counter * 2;
},
});
expect(derived.doubleCounter).toBe(2);
});
test("computed properties are recomputed when dependencies change", async () => {
const source = reactive({ counter: 1 });
const derived = withComputedProperties(reactive({}), [source], {
doubleCounter(source) {
return source.counter * 2;
},
});
expect(derived.doubleCounter).toBe(2);
source.counter++;
expect(derived.doubleCounter).toBe(4);
});
test("can observe computed properties", async () => {
const source = reactive({ counter: 1 });
const derived = withComputedProperties(reactive({}), [source], {
doubleCounter(source) {
return source.counter * 2;
},
});
const observed = reactive(derived, () => {
expect.step(`doubleCounter: ${observed.doubleCounter}`);
});
observed.doubleCounter; // subscribe to doubleCounter
expect.verifySteps([]);
source.counter++;
expect.verifySteps(["doubleCounter: 4"]);
});
test("computed properties can use nested objects", async () => {
const source = reactive({ subObj: { counter: 1 } });
const derived = withComputedProperties(reactive({}), [source], {
doubleCounter(source) {
return source.subObj.counter * 2;
},
});
const observed = reactive(derived, () => {
expect.step(`doubleCounter: ${observed.doubleCounter}`);
});
observed.doubleCounter; // subscribe to doubleCounter
expect(derived.doubleCounter).toBe(2);
expect.verifySteps([]);
source.subObj.counter++;
expect(derived.doubleCounter).toBe(4);
// reactive gets notified even for computed properties dervied from nested objects
expect.verifySteps(["doubleCounter: 4"]);
});
});

View file

@ -0,0 +1,273 @@
import { test, expect } from "@odoo/hoot";
import { MultiRecordSelector } from "@web/core/record_selectors/multi_record_selector";
import { Component, useState, xml } from "@odoo/owl";
import {
contains,
defineModels,
fields,
models,
mountWithCleanup,
onRpc,
} from "@web/../tests/web_test_helpers";
import { click, fill, press, queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
class Partner extends models.Model {
_name = "partner";
name = fields.Char();
_records = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Charlie" },
];
}
// Required for the select create dialog
class Users extends models.Model {
_name = "res.users";
has_group = () => true;
}
defineModels([Partner, Users]);
async function mountMultiRecordSelector(props) {
class Parent extends Component {
static components = { MultiRecordSelector };
static template = xml`<MultiRecordSelector t-props="recordProps" />`;
static props = ["*"];
setup() {
this.state = useState({ resIds: props.resIds });
}
get recordProps() {
return {
...props,
resIds: this.state.resIds,
update: (resIds) => this._update(resIds),
};
}
_update(resIds) {
this.state.resIds = resIds;
}
}
await mountWithCleanup(Parent);
}
test("Can be renderer with no values", async () => {
await mountMultiRecordSelector({
resModel: "partner",
resIds: [],
});
expect(".o_multi_record_selector input").toHaveValue("");
expect(".o_multi_record_selector input").toHaveClass("o_input");
});
test("Can be renderer with a value", async () => {
await mountMultiRecordSelector({
resModel: "partner",
resIds: [1],
});
expect(".o_multi_record_selector input").toHaveValue("");
expect(".o_tag").toHaveCount(1);
expect(".o_tag").toHaveText("Alice");
});
test("Can be renderer with multiple values", async () => {
await mountMultiRecordSelector({
resModel: "partner",
resIds: [1, 2],
});
expect(".o_multi_record_selector input").toHaveValue("");
expect(".o_tag").toHaveCount(2);
expect(queryAllTexts(".o_tag")).toEqual(["Alice", "Bob"]);
});
test("Can be updated from autocomplete", async () => {
await mountMultiRecordSelector({
resModel: "partner",
resIds: [],
});
expect(".o_multi_record_selector input").toHaveValue("");
expect(".o_tag").toHaveCount(0);
expect(".o-autocomplete--dropdown-menu").toHaveCount(0);
await click(".o_multi_record_selector input");
await animationFrame();
expect(".o-autocomplete--dropdown-menu").toHaveCount(1);
await click("li.o-autocomplete--dropdown-item:eq(1)");
await animationFrame();
expect(".o_tag").toHaveCount(1);
expect(".o_tag").toHaveText("Bob");
});
test("Display name is correctly fetched", async () => {
expect.assertions(4);
onRpc("partner", "web_search_read", ({ kwargs }) => {
expect.step("web_search_read");
expect(kwargs.domain).toEqual([["id", "in", [1]]]);
});
await mountMultiRecordSelector({
resModel: "partner",
resIds: [1],
});
expect(".o_tag").toHaveCount(1);
expect(".o_tag").toHaveText("Alice");
expect.verifySteps(["web_search_read"]);
});
test("Can give domain and context props for the name search", async () => {
expect.assertions(4);
onRpc("partner", "name_search", ({ kwargs }) => {
expect.step("name_search");
expect(kwargs.args).toEqual(["&", ["display_name", "=", "Bob"], "!", ["id", "in", [1]]]);
expect(kwargs.context.blip).toBe("blop");
});
await mountMultiRecordSelector({
resModel: "partner",
resIds: [1],
domain: [["display_name", "=", "Bob"]],
context: { blip: "blop" },
});
expect.verifySteps([]);
await click(".o_multi_record_selector input");
await animationFrame();
expect.verifySteps(["name_search"]);
});
test("Support placeholder", async () => {
await mountMultiRecordSelector({
resModel: "partner",
resIds: [],
placeholder: "Select a partner",
});
expect(".o_multi_record_selector input").toHaveAttribute("placeholder", "Select a partner");
await click(".o_multi_record_selector input");
await animationFrame();
await contains("li.o-autocomplete--dropdown-item:eq(0)").click();
expect(".o_multi_record_selector input").toHaveAttribute("placeholder", "");
});
test("Placeholder is not set if values are selected", async () => {
await mountMultiRecordSelector({
resModel: "partner",
resIds: [1],
placeholder: "Select a partner",
});
expect(".o_multi_record_selector input").toHaveAttribute("placeholder", "");
});
test("Can delete a tag with Backspace", async () => {
await mountMultiRecordSelector({
resModel: "partner",
resIds: [1, 2],
});
await click(".o_multi_record_selector input");
await animationFrame();
await press("Backspace");
await animationFrame();
expect(".o_tag").toHaveCount(1);
expect(".o_tag").toHaveText("Alice");
});
test("Can focus tags with arrow right and left", async () => {
await mountMultiRecordSelector({
resModel: "partner",
resIds: [1, 2],
});
// Click twice because to get the focus and make disappear the autocomplete popover
await click(".o_multi_record_selector input");
await click(".o_multi_record_selector input");
await animationFrame();
await press("arrowleft");
await animationFrame();
expect(document.activeElement).toHaveText("Bob");
await press("arrowleft");
await animationFrame();
expect(document.activeElement).toHaveText("Alice");
await press("arrowleft");
await animationFrame();
expect(document.activeElement).toHaveClass("o-autocomplete--input");
await press("arrowright");
await animationFrame();
expect(document.activeElement).toHaveText("Alice");
await press("arrowright");
await animationFrame();
expect(document.activeElement).toHaveText("Bob");
await press("arrowright");
await animationFrame();
expect(document.activeElement).toHaveClass("o-autocomplete--input");
});
test("Delete the focused element", async () => {
await mountMultiRecordSelector({
resModel: "partner",
resIds: [1, 2],
});
// Click twice because to get the focus and make disappear the autocomplete popover
await click(".o_multi_record_selector input");
await click(".o_multi_record_selector input");
await animationFrame();
await press("arrowright");
await animationFrame();
expect(document.activeElement).toHaveText("Alice");
await press("Backspace");
await animationFrame();
expect(".o_tag").toHaveCount(1);
expect(".o_tag").toHaveText("Bob");
});
test("Backspace do nothing when the input is currently edited", async () => {
await mountMultiRecordSelector({
resModel: "partner",
resIds: [1, 2],
});
await click(".o-autocomplete input");
await animationFrame();
await fill("a");
await animationFrame();
expect(document.activeElement).toHaveValue("a");
await press("Backspace");
await animationFrame();
expect(".o_tag").toHaveCount(2);
});
// Desktop only because a kanban view is used instead of a list in mobile
test.tags("desktop");
test("Can pass domain to search more", async () => {
Partner._records.push(
{ id: 4, name: "David" },
{ id: 5, name: "Eve" },
{ id: 6, name: "Frank" },
{ id: 7, name: "Grace" },
{ id: 8, name: "Helen" },
{ id: 9, name: "Ivy" }
);
Partner._views["list"] = /* xml */ `<list><field name="name"/></list>`;
await mountMultiRecordSelector({
resModel: "partner",
resIds: [],
domain: [["id", "not in", [1]]],
});
await click(".o-autocomplete input");
await animationFrame();
await click(".o_multi_record_selector .o_m2o_dropdown_option");
await animationFrame();
expect(".o_data_row").toHaveCount(8, { message: "should contain 8 records" });
});

View file

@ -0,0 +1,131 @@
import { test, expect } from "@odoo/hoot";
import { RecordSelector } from "@web/core/record_selectors/record_selector";
import { Component, useState, xml } from "@odoo/owl";
import {
defineModels,
fields,
models,
mountWithCleanup,
onRpc,
} from "@web/../tests/web_test_helpers";
import { animationFrame } from "@odoo/hoot-mock";
import { click } from "@odoo/hoot-dom";
class Partner extends models.Model {
_name = "partner";
name = fields.Char();
_records = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Charlie" },
];
}
defineModels([Partner]);
async function mountRecordSelector(props) {
class Parent extends Component {
static components = { RecordSelector };
static template = xml`<RecordSelector t-props="recordProps" />`;
static props = ["*"];
setup() {
this.state = useState({ resId: props.resId });
}
get recordProps() {
return {
...props,
resId: this.state.resId,
update: (resId) => this._update(resId),
};
}
_update(resId) {
this.state.resId = resId;
}
}
await mountWithCleanup(Parent);
}
test("Can be renderer with no values", async () => {
await mountRecordSelector({
resModel: "partner",
resId: false,
});
expect(".o_record_selector input").toHaveValue("");
expect(".o_record_selector input").toHaveClass("o_input");
});
test("Can be renderer with a value", async () => {
await mountRecordSelector({
resModel: "partner",
resId: 1,
});
expect(".o_record_selector input").toHaveValue("Alice");
});
test("Can be updated from autocomplete", async () => {
await mountRecordSelector({
resModel: "partner",
resId: 1,
});
expect(".o_record_selector input").toHaveValue("Alice");
expect(".o-autocomplete--dropdown-menu").toHaveCount(0);
await click(".o_record_selector input");
await animationFrame();
expect(".o-autocomplete--dropdown-menu").toHaveCount(1);
await click("li.o-autocomplete--dropdown-item:eq(1)");
await animationFrame();
expect(".o_record_selector input").toHaveValue("Bob");
});
test("Display name is correctly fetched", async () => {
expect.assertions(3);
onRpc("partner", "web_search_read", ({ kwargs }) => {
expect.step("web_search_read");
expect(kwargs.domain).toEqual([["id", "in", [1]]]);
});
await mountRecordSelector({
resModel: "partner",
resId: 1,
});
expect(".o_record_selector input").toHaveValue("Alice");
expect.verifySteps(["web_search_read"]);
});
test("Can give domain and context props for the name search", async () => {
expect.assertions(5);
onRpc("partner", "name_search", ({ kwargs }) => {
expect.step("name_search");
expect(kwargs.args).toEqual(["&", ["display_name", "=", "Bob"], "!", ["id", "in", []]]);
expect(kwargs.context.blip).toBe("blop");
});
await mountRecordSelector({
resModel: "partner",
resId: 1,
domain: [["display_name", "=", "Bob"]],
context: { blip: "blop" },
});
expect(".o_record_selector input").toHaveValue("Alice");
expect.verifySteps([]);
await click(".o_record_selector input");
await animationFrame();
expect.verifySteps(["name_search"]);
});
test("Support placeholder", async () => {
await mountRecordSelector({
resModel: "partner",
resId: false,
placeholder: "Select a partner",
});
expect(".o_record_selector input").toHaveAttribute("placeholder", "Select a partner");
});

View file

@ -0,0 +1,207 @@
import { describe, expect, test } from "@odoo/hoot";
import { Component } from "@odoo/owl";
import { serverState } from "@web/../tests/web_test_helpers";
import { Registry } from "@web/core/registry";
describe.current.tags("headless");
test("key set and get", () => {
const registry = new Registry();
const foo = {};
registry.add("foo", foo);
expect(registry.get("foo")).toBe(foo);
});
test("can set and get falsy values", () => {
const registry = new Registry();
registry.add("foo1", false);
registry.add("foo2", 0);
registry.add("foo3", "");
registry.add("foo4", undefined);
registry.add("foo5", null);
expect(registry.get("foo1")).toBe(false);
expect(registry.get("foo2")).toBe(0);
expect(registry.get("foo3")).toBe("");
expect(registry.get("foo4")).toBe(undefined);
expect(registry.get("foo5")).toBe(null);
});
test("can set and get falsy values with default value", () => {
const registry = new Registry();
registry.add("foo1", false);
registry.add("foo2", 0);
registry.add("foo3", "");
registry.add("foo4", undefined);
registry.add("foo5", null);
expect(registry.get("foo1", 1)).toBe(false);
expect(registry.get("foo2", 1)).toBe(0);
expect(registry.get("foo3", 1)).toBe("");
expect(registry.get("foo4", 1)).toBe(undefined);
expect(registry.get("foo5", 1)).toBe(null);
});
test("can get a default value when missing key", () => {
const registry = new Registry();
expect(registry.get("missing", "default")).toBe("default");
expect(registry.get("missing", null)).toBe(null);
expect(registry.get("missing", false)).toBe(false);
});
test("throws if key is missing", () => {
const registry = new Registry();
expect(() => registry.get("missing")).toThrow();
});
test("contains method", () => {
const registry = new Registry();
registry.add("foo", 1);
expect(registry.contains("foo")).toBe(true);
expect(registry.contains("bar")).toBe(false);
});
test("can set and get a value, with an order arg", () => {
const registry = new Registry();
const foo = {};
registry.add("foo", foo, { sequence: 24 });
expect(registry.get("foo")).toBe(foo);
});
test("can get ordered list of elements", () => {
const registry = new Registry();
registry
.add("foo1", "foo1", { sequence: 1 })
.add("foo2", "foo2", { sequence: 2 })
.add("foo5", "foo5", { sequence: 5 })
.add("foo3", "foo3", { sequence: 3 });
expect(registry.getAll()).toEqual(["foo1", "foo2", "foo3", "foo5"]);
});
test("can get ordered list of entries", () => {
const registry = new Registry();
registry
.add("foo1", "foo1", { sequence: 1 })
.add("foo2", "foo2", { sequence: 2 })
.add("foo5", "foo5", { sequence: 5 })
.add("foo3", "foo3", { sequence: 3 });
expect(registry.getEntries()).toEqual([
["foo1", "foo1"],
["foo2", "foo2"],
["foo3", "foo3"],
["foo5", "foo5"],
]);
});
test("getAll and getEntries returns shallow copies", () => {
const registry = new Registry();
registry.add("foo1", "foo1");
const all = registry.getAll();
const entries = registry.getEntries();
expect(all).toEqual(["foo1"]);
expect(entries).toEqual([["foo1", "foo1"]]);
all.push("foo2");
entries.push(["foo2", "foo2"]);
expect(all).toEqual(["foo1", "foo2"]);
expect(entries).toEqual([
["foo1", "foo1"],
["foo2", "foo2"],
]);
expect(registry.getAll()).toEqual(["foo1"]);
expect(registry.getEntries()).toEqual([["foo1", "foo1"]]);
});
test("can override element with sequence", () => {
const registry = new Registry();
registry
.add("foo1", "foo1", { sequence: 1 })
.add("foo2", "foo2", { sequence: 2 })
.add("foo1", "foo3", { force: true });
expect(registry.getEntries()).toEqual([
["foo1", "foo3"],
["foo2", "foo2"],
]);
});
test("can override element with sequence 2 ", () => {
const registry = new Registry();
registry
.add("foo1", "foo1", { sequence: 1 })
.add("foo2", "foo2", { sequence: 2 })
.add("foo1", "foo3", { force: true, sequence: 3 });
expect(registry.getEntries()).toEqual([
["foo2", "foo2"],
["foo1", "foo3"],
]);
});
test("can recursively open sub registry", () => {
const registry = new Registry();
registry.category("sub").add("a", "b");
expect(registry.category("sub").get("a")).toBe("b");
});
test("can validate the values from a schema", () => {
serverState.debug = "1";
const schema = { name: String, age: { type: Number, optional: true } };
const friendsRegistry = new Registry();
friendsRegistry.addValidation(schema);
expect(() => friendsRegistry.add("jean", { name: "Jean" })).not.toThrow();
expect(friendsRegistry.get("jean")).toEqual({ name: "Jean" });
expect(() => friendsRegistry.add("luc", { name: "Luc", age: 32 })).not.toThrow();
expect(friendsRegistry.get("luc")).toEqual({ name: "Luc", age: 32 });
expect(() => friendsRegistry.add("adrien", { name: 23 })).toThrow();
expect(() => friendsRegistry.add("hubert", { age: 54 })).toThrow();
expect(() => friendsRegistry.add("chris", { name: "chris", city: "Namur" })).toThrow();
expect(() => friendsRegistry.addValidation({ something: Number })).toThrow();
});
test("can validate by adding a schema after the registry is filled", async () => {
serverState.debug = "1";
const schema = { name: String };
const friendsRegistry = new Registry();
expect(() => friendsRegistry.add("jean", { name: 999 })).not.toThrow();
expect(() => friendsRegistry.addValidation(schema)).toThrow();
});
test("can validate subclassess", async () => {
serverState.debug = "1";
const schema = { component: { validate: (c) => c.prototype instanceof Component } };
const widgetRegistry = new Registry();
widgetRegistry.addValidation(schema);
class Widget extends Component {} // eslint-disable-line
expect(() => widgetRegistry.add("calculator", { component: Widget })).not.toThrow({
message: "Support subclasses",
});
});
test("only validate in debug", async () => {
const schema = { name: String };
const registry = new Registry();
registry.addValidation(schema);
expect(() => registry.add("jean", { name: 50 })).not.toThrow({
message: "There is no validation if not in debug mode",
});
});

View file

@ -0,0 +1,163 @@
import { describe, expect, test } from "@odoo/hoot";
import { drag, queryOne, queryRect, resize } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, reactive, xml } from "@odoo/owl";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { ResizablePanel } from "@web/core/resizable_panel/resizable_panel";
describe.current.tags("desktop");
test("Width cannot exceed viewport width", async () => {
class Parent extends Component {
static components = { ResizablePanel };
static template = xml`
<ResizablePanel>
<p>A</p>
<p>Cool</p>
<p>Paragraph</p>
</ResizablePanel>
`;
static props = ["*"];
}
await mountWithCleanup(Parent);
expect(".o_resizable_panel").toHaveCount(1);
expect(".o_resizable_panel_handle").toHaveCount(1);
const vw = window.innerWidth;
queryOne(".o_resizable_panel").style.width = `${vw + 100}px`;
expect(queryRect(".o_resizable_panel").width).toBeWithin(vw * 0.95, vw);
});
test("handles right-to-left", async () => {
class Parent extends Component {
static components = { ResizablePanel };
static template = xml`
<div class="d-flex parent-el" style="direction: rtl;">
<div style="width: 50px;" />
<ResizablePanel minWidth="20" initialWidth="30">
<div style="width: 10px;" class="text-break">
A cool paragraph
</div>
</ResizablePanel>
</div>
`;
static props = ["*"];
}
await mountWithCleanup(Parent);
expect(".o_resizable_panel").toHaveRect({ width: 30 });
await (
await drag(".o_resizable_panel_handle")
).drop(".o_resizable_panel_handle", {
position: {
x: 10,
},
});
expect(queryRect(".o_resizable_panel").width).toBeGreaterThan(
queryOne(".parent-el").offsetWidth - 10 - 50
);
});
test("handles resize handle at start in fixed position", async () => {
class Parent extends Component {
static components = { ResizablePanel };
static template = xml`
<div class="d-flex parent-el">
<ResizablePanel minWidth="20" initialWidth="30" handleSide="'start'" class="'position-fixed'">
<div style="width: 10px;" class="text-break">
A cool paragraph
</div>
</ResizablePanel>
</div>
`;
static props = ["*"];
}
await mountWithCleanup(Parent);
const resizablePanelEl = queryOne(".o_resizable_panel");
resizablePanelEl.style.setProperty("right", "100px");
expect(resizablePanelEl).toHaveRect({ width: 30 });
await (
await drag(".o_resizable_panel_handle")
).drop(".o_resizable_panel_handle", {
position: {
x: window.innerWidth - 200,
},
});
expect(resizablePanelEl).toHaveRect({
width: 100 + queryRect(".o_resizable_panel_handle").width / 2,
});
});
test("resizing the window adapts the panel", async () => {
class Parent extends Component {
static components = { ResizablePanel };
static template = xml`
<div style="width: 400px;" class="parent-el position-relative">
<ResizablePanel>
<p>A</p>
<p>Cool</p>
<p>Paragraph</p>
</ResizablePanel>
</div>
`;
static props = ["*"];
}
await mountWithCleanup(Parent);
await (
await drag(".o_resizable_panel_handle")
).drop(".o_resizable_panel_handle", {
position: {
x: 99999,
},
});
expect(queryOne(".o_resizable_panel").offsetWidth).toBe(398);
queryOne(".parent-el").style.width = "200px";
await resize();
expect(queryOne(".o_resizable_panel").offsetWidth).toBe(198);
});
test("minWidth props can be updated", async () => {
class Parent extends Component {
static components = { ResizablePanel };
static template = xml`
<div class="d-flex">
<ResizablePanel minWidth="props.state.minWidth">
<div style="width: 10px;" class="text-break">
A cool paragraph
</div>
</ResizablePanel>
</div>
`;
static props = ["*"];
}
const state = reactive({ minWidth: 20 });
await mountWithCleanup(Parent, {
props: { state },
});
await (
await drag(".o_resizable_panel_handle")
).drop(".o_resizable_panel_handle", {
position: {
x: 15,
},
});
expect(".o_resizable_panel").toHaveRect({ width: 20 });
state.minWidth = 40;
await animationFrame();
await (
await drag(".o_resizable_panel_handle")
).drop(".o_resizable_panel_handle", {
position: {
x: 15,
},
});
expect(".o_resizable_panel").toHaveRect({ width: 40 });
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,543 @@
import { expect, test } from "@odoo/hoot";
import { Component, xml } from "@odoo/owl";
import { getService, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { click, queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { browser } from "@web/core/browser/browser";
import { scrollTo } from "@web/core/utils/scrolling";
import { WebClient } from "@web/webclient/webclient";
import { registry } from "@web/core/registry";
import { redirect } from "@web/core/utils/urls";
test("Ignore empty hrefs", async () => {
class MyComponent extends Component {
static template = xml/* xml */ `
<div class="my_component">
<a href="#" class="inactive_link">This link does nothing</a>
<button class="btn btn-secondary">
<a href="#">
<i class="fa fa-trash"/>
</a>
</button>
</div>`;
static props = ["*"];
}
await mountWithCleanup(MyComponent);
browser.location.hash = "#testscroller";
await click(".inactive_link");
await animationFrame();
await click(".fa.fa-trash");
await animationFrame();
expect(browser.location.hash).toBe("#testscroller");
});
test("Simple rendering with a scroll", async () => {
class MyComponent extends Component {
static template = xml/* xml */ `
<div id="scroller" style="overflow: scroll; width: 400px; height: 150px">
<div class="o_content">
<a href="#scrollToHere" class="btn btn-primary">sroll to ...</a>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus.
Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed,
dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper
congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est
eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu
massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut
in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent
egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue
blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices
posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque
fermentum. Maecenas adipiscing ante non diam sodales hendrerit.
</p>
<p>
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa
suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc
turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus
nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis
metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing
elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor
tellus, aliquam faucibus, convallis id, congue eu, quam. Mauris ullamcorper felis
vitae erat. Proin feugiat, augue non elementum posuere, metus purus iaculis lectus,
et tristique ligula justo vitae magna.
</p>
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus,
felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet.
</p>
<div id="scrollToHere">sroll here!</div>
</div>
</div>
`;
static props = ["*"];
}
await mountWithCleanup(MyComponent);
expect(queryOne("#scroller").scrollTop).toBe(0);
await click(".btn.btn-primary");
await animationFrame();
expect(queryOne("#scroller").scrollTop).toBeGreaterThan(0);
});
test("clicking to scroll on a web client shouldn't open the default app", async (assert) => {
expect.assertions(2);
class MyComponent extends Component {
static template = xml/* xml */ `
<div class="o_content" style="overflow:scroll;height:150px;width:400px">
<a href="#scrollToHere" class="alert-link" role="button">sroll to ...</a>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus.
Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed,
dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper
congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est
eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu
massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut
in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent
egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue
blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices
posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque
fermentum. Maecenas adipiscing ante non diam sodales hendrerit.
</p>
<p>
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa
suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc
turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus
nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis
metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing
elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor
tellus, aliquam faucibus, convallis id, congue eu, quam. Mauris ullamcorper felis
vitae erat. Proin feugiat, augue non elementum posuere, metus purus iaculis lectus,
et tristique ligula justo vitae magna.
</p>
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus,
felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet.
</p>
<div id="scrollToHere">sroll here!</div>
</div>
`;
static props = ["*"];
static path = "my_component";
}
registry.category("actions").add("my_component", MyComponent);
await mountWithCleanup(WebClient);
await getService("action").doAction("my_component");
const scrollableParent = document.querySelector(".o_content");
expect(scrollableParent.scrollTop).toBe(0);
await click(".alert-link");
expect(scrollableParent.scrollTop).not.toBe(0);
});
test("Rendering with multiple anchors and scrolls", async () => {
class MyComponent extends Component {
static template = xml/* xml */ `
<div id="scroller" style="overflow: scroll; width: 400px; height: 150px">
<div class="o_content">
<h2 id="anchor3">ANCHOR 3</h2>
<a href="#anchor1" class="link1">sroll to ...</a>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus.
Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed,
dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper
congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est
eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu
massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut
in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent
egestas leo in pede. Praesent blandit odio eu enim.
</p>
<p>
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa
suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc
turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus
nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis
metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing
elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor
tellus, aliquam faucibus, convallis id, congue eu, quam.
</p>
<table>
<tbody>
<tr>
<td>
<h2 id="anchor2">ANCHOR 2</h2>
<a href="#anchor3" class="link3">TO ANCHOR 3</a>
<p>
The table forces you to get the precise position of the element.
</p>
</td>
</tr>
</tbody>
</table>
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue.
</p>
<div id="anchor1">sroll here!</div>
<a href="#anchor2" class="link2">TO ANCHOR 2</a>
</div>
</div>
`;
static props = ["*"];
}
await mountWithCleanup(MyComponent);
const scrollableParent = queryOne("#scroller");
expect(scrollableParent.scrollTop).toBe(0);
await click(".link1");
// The element must be contained in the scrollable parent (top and bottom)
const isVisible = (selector) => {
const el = queryOne(selector);
return (
el.getBoundingClientRect().bottom <= scrollableParent.getBoundingClientRect().bottom &&
el.getBoundingClientRect().top >= scrollableParent.getBoundingClientRect().top
);
};
expect(isVisible("#anchor1")).toBe(true);
await click(".link2");
await animationFrame();
expect(isVisible("#anchor2")).toBe(true);
await click(".link3");
await animationFrame();
expect(isVisible("#anchor3")).toBe(true);
});
test("clicking anchor when no scrollable", async () => {
class MyComponent extends Component {
static template = xml/* xml */ `
<div id="scroller" style="overflow: auto; width: 400px; height: 150px">
<div class="o_content">
<a href="#scrollToHere" class="btn btn-primary">scroll to ...</a>
<div class="active-container">
<p>There is no scrollable with only the height of this element</p>
</div>
<div class="inactive-container" style="max-height: 0; overflow: hidden">
<h2>There should be no scrollable if this element has 0 height</h2>
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus,
felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet.
</p>
<div id="scrollToHere">should try to scroll here only if scrollable!</div>
</div>
</div>
</div>
`;
static props = ["*"];
}
await mountWithCleanup(MyComponent);
const scrollableParent = queryOne("#scroller");
expect(scrollableParent.scrollTop).toBe(0);
await click(".btn.btn-primary");
await animationFrame();
expect(scrollableParent.scrollTop).toBe(0, { message: "no scroll happened" });
queryOne(".inactive-container").style.maxHeight = "unset";
await click(".btn.btn-primary");
await animationFrame();
expect(scrollableParent.scrollTop).toBeGreaterThan(0, { message: "a scroll happened" });
});
test("clicking anchor when multi levels scrollables", async () => {
class MyComponent extends Component {
static template = xml/* xml */ `
<div id="scroller" style="overflow: auto; width: 400px; height: 150px">
<div class="o_content scrollable-1">
<a href="#scroll1" class="btn1 btn btn-primary">go to level 2 anchor</a>
<div>
<p>This is some content</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus.
Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed,
dolor. Cras elementum ultrices diam. Maecenas
</p>
</div>
<div class="scrollable-2" style="background: green; overflow: auto; height: 100px;">
<h2>This is level 1 of scrollable</h2>
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus,
felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet.
</p>
<div style="background: lime;">
<h2>This is level 2 of scrollable</h2>
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus,
felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet.
</p>
<div id="scroll1" style="background: orange;">this element is contained in a scrollable metaverse!</div>
<a href="#scroll2" class="btn2 btn btn-primary">go to level 1 anchor</a>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus.
Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed,
dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper
congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est
eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu
massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut
in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent
egestas leo in pede. Praesent blandit odio eu enim.
</p>
</div>
</div>
<div id="scroll2" style="background: orange;">this is an anchor at level 1!</div>
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus,
felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet.
</p>
</div>
</div>
`;
static props = ["*"];
}
await mountWithCleanup(MyComponent);
const scrollableParent = queryOne("#scroller");
const border = (selector) => {
const el = queryOne(selector);
// Returns the state of the element in relation to the borders
const element = el.getBoundingClientRect();
const scrollable = scrollableParent.getBoundingClientRect();
return {
top: parseInt(element.top - scrollable.top),
bottom: parseInt(scrollable.bottom - element.bottom),
};
};
expect(scrollableParent.scrollTop).toBe(0);
await click(".btn1");
await animationFrame();
expect(border("#scroll1").top).toBeLessThan(10, {
message: "the element must be near the top border",
});
await click(".btn2");
await animationFrame();
expect(border("#scroll2").top).toBeLessThan(10, {
message: "the element must be near the top border",
});
});
test("Simple scroll to HTML elements", async () => {
class MyComponent extends Component {
static template = xml/* xml */ `
<div id="scroller" style="overflow: auto; width: 400px; height: 150px">
<div class="o_content">
<p>
Aliquam convallis sollicitudin purus.
</p>
<div id="o-div-1">A div is an HTML element</div>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus.
Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed,
dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper
congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est
eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu
massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut
in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent
egestas leo in pede. Praesent blandit odio eu enim.
</p>
<p>
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa
suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc
turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus
nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis
metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing
elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor
tellus, aliquam faucibus, convallis id, congue eu, quam.
</p>
<div id="o-div-2">A div is an HTML element</div>
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue.
</p>
<div id="fake-scrollable">
<div id="o-div-3">A div is an HTML element</div>
</div>
<div id="sub-scrollable">
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue.
</p>
<div id="o-div-4">A div is an HTML element</div>
</div>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus.
Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed,
dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper
congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est
eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu
massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut
in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent
egestas leo in pede. Praesent blandit odio eu enim.
</p>
<p>
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa
suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc
turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus
nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis
metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing
elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor
tellus, aliquam faucibus, convallis id, congue eu, quam.
</p>
</div>
</div>
`;
static props = ["*"];
}
await mountWithCleanup(MyComponent);
const scrollableParent = queryOne("#scroller");
// The element must be contained in the scrollable parent (top and bottom)
const isVisible = (selector) => {
const el = queryOne(selector);
return (
el.getBoundingClientRect().bottom <= scrollableParent.getBoundingClientRect().bottom &&
el.getBoundingClientRect().top >= scrollableParent.getBoundingClientRect().top
);
};
const border = (selector) => {
const el = queryOne(selector);
// Returns the state of the element in relation to the borders
const element = el.getBoundingClientRect();
const scrollable = scrollableParent.getBoundingClientRect();
return {
top: parseInt(element.top - scrollable.top),
bottom: parseInt(scrollable.bottom - element.bottom),
};
};
// When using scrollTo to an element, this should just scroll
// until the element is visible in the scrollable parent
const subScrollable = queryOne("#sub-scrollable");
subScrollable.style.overflowY = "scroll";
subScrollable.style.height = getComputedStyle(subScrollable)["line-height"];
subScrollable.style.width = "300px";
expect(isVisible("#o-div-1")).toBe(true);
expect(isVisible("#o-div-2")).toBe(false);
expect(border("#o-div-1").top).not.toBe(0);
scrollTo(queryOne("#o-div-2"));
expect(isVisible("#o-div-1")).toBe(false);
expect(isVisible("#o-div-2")).toBe(true);
expect(border("#o-div-2").bottom).toBe(0);
scrollTo(queryOne("#o-div-1"));
expect(isVisible("#o-div-3")).toBe(false);
expect(isVisible("#o-div-4")).toBe(false);
expect(border("#o-div-1").top).toBe(0);
// Specify a scrollable which can not be scrolled, the effective scrollable
// should be its closest actually scrollable parent.
scrollTo(queryOne("#o-div-3"), { scrollable: queryOne("#fake-scrollable") });
expect(isVisible("#o-div-3")).toBe(true);
expect(isVisible("#o-div-4")).toBe(false);
expect(border("#o-div-3").bottom).toBe(0);
// Reset the position
scrollTo(queryOne("#o-div-1"));
expect(isVisible("#o-div-1")).toBe(true);
expect(isVisible("#o-div-3")).toBe(false);
expect(isVisible("#o-div-4")).toBe(false);
// Scrolling should be recursive in case of a hierarchy of
// scrollables, if `isAnchor` is set to `true`, and it must be scrolled
// to the top even if it was positioned below the scroll view.
scrollTo(queryOne("#o-div-4"), { isAnchor: true });
expect(isVisible("#o-div-4")).toBe(true);
expect(border("#o-div-4").top).toBe(0);
expect(border("#sub-scrollable").top).toBe(0);
});
test("scroll to anchor from load", async () => {
class MyComponent extends Component {
static template = xml/* xml */ `
<div class="o_content" style="overflow:scroll;height:150px;width:400px">
<a href="#scrollToHere" class="alert-link" role="button">sroll to ...</a>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus.
Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed,
dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper
congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est
eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu
massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut
in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent
egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue
blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices
posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque
fermentum. Maecenas adipiscing ante non diam sodales hendrerit.
</p>
<p>
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa
suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc
turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus
nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis
metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing
elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor
tellus, aliquam faucibus, convallis id, congue eu, quam. Mauris ullamcorper felis
vitae erat. Proin feugiat, augue non elementum posuere, metus purus iaculis lectus,
et tristique ligula justo vitae magna.
</p>
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus,
felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet.
</p>
<div id="scrollToHere">sroll here!</div>
</div>
`;
static props = ["*"];
static path = "my_component";
}
registry.category("actions").add("my_component", MyComponent);
redirect("/odoo/my_component#scrollToHere");
await mountWithCleanup(WebClient);
await animationFrame();
const scrollableParent = document.querySelector(".o_content");
expect(scrollableParent.scrollTop).not.toBe(0);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,201 @@
import { expect, test } from "@odoo/hoot";
import { click, queryAttribute } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, useState, xml } from "@odoo/owl";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { TagsList } from "@web/core/tags_list/tags_list";
test("Can be rendered with different tags", async () => {
class Parent extends Component {
static props = ["*"];
static components = { TagsList };
static template = xml`<TagsList tags="tags" />`;
setup() {
this.tags = [
{
id: "tag1",
text: "Earth",
},
{
colorIndex: 1,
id: "tag2",
text: "Wind",
onDelete: () => {
expect.step(`tag2 delete button has been clicked`);
},
},
{
colorIndex: 2,
id: "tag3",
text: "Fire",
onClick: () => {
expect.step(`tag3 has been clicked`);
},
},
];
}
}
await mountWithCleanup(Parent);
expect(".o_tag").toHaveCount(3);
await click(".o_tag:nth-of-type(2) .o_delete");
expect.verifySteps(["tag2 delete button has been clicked"]);
await click(".o_tag:nth-of-type(3)");
expect.verifySteps(["tag3 has been clicked"]);
});
test("Tags can be displayed with an image", async () => {
class Parent extends Component {
static props = ["*"];
static components = { TagsList };
static template = xml`<TagsList tags="tags" />`;
setup() {
this.tags = [
{
img: "fake/url",
id: "tag1",
text: "Earth",
},
{
img: "fake/url/2",
id: "tag2",
text: "Wind",
},
];
}
}
await mountWithCleanup(Parent);
expect(".o_tag").toHaveCount(2);
expect(".o_tag:nth-of-type(1) img").toHaveAttribute("data-src", "fake/url");
expect(".o_tag:nth-of-type(2) img").toHaveAttribute("data-src", "fake/url/2");
});
test("Tags can be displayed with an icon", async () => {
class Parent extends Component {
static props = ["*"];
static components = { TagsList };
static template = xml`<TagsList tags="tags" />`;
setup() {
this.tags = [
{
icon: "fa-trash",
id: "tag1",
text: "Bad",
},
{
icon: "fa-check",
id: "tag2",
text: "Good",
},
];
}
}
await mountWithCleanup(Parent);
expect(".o_tag").toHaveCount(2);
expect(".o_tag:nth-of-type(1) i").toHaveClass("fa fa-trash");
expect(".o_tag:nth-of-type(2) i").toHaveClass("fa fa-check");
});
test("Limiting the visible tags displays a counter", async () => {
class Parent extends Component {
static props = ["*"];
static components = { TagsList };
static template = xml`<TagsList tags="tags" visibleItemsLimit="state.visibleItemsLimit" />`;
setup() {
this.state = useState({
visibleItemsLimit: 3,
});
this.tags = [
{
id: "tag1",
text: "Water",
onDelete: () => {},
},
{
id: "tag2",
text: "Grass",
},
{
id: "tag3",
text: "Fire",
},
{
id: "tag4",
text: "Earth",
},
{
id: "tag5",
text: "Wind",
},
{
id: "tag6",
text: "Dust",
},
];
}
}
const parent = await mountWithCleanup(Parent);
// visibleItemsLimit = 3 -> displays 2 tags + 1 counter (4 tags left)
expect(".o_tag").toHaveCount(2);
expect(".rounded").toHaveText("+4", {
message: "the counter displays 4 more items",
});
expect(JSON.parse(queryAttribute(".rounded", "data-tooltip-info"))).toEqual(
{
tags: [
{ text: "Fire", id: "tag3" },
{ text: "Earth", id: "tag4" },
{ text: "Wind", id: "tag5" },
{ text: "Dust", id: "tag6" },
],
},
{ message: "the counter has a tooltip displaying other items" }
);
parent.state.visibleItemsLimit = 5;
await animationFrame();
// visibleItemsLimit = 5 -> displays 4 tags + 1 counter (2 tags left)
expect(".o_tag").toHaveCount(4);
expect(".rounded").toHaveText("+2");
parent.state.visibleItemsLimit = 6;
await animationFrame();
// visibleItemsLimit = 6 -> displays 6 tags + 0 counter (0 tag left)
expect(".o_tag").toHaveCount(6);
expect(".rounded").toHaveCount(0);
});
test("Tags with img have a backdrop only if they can be deleted", async () => {
class Parent extends Component {
static props = ["*"];
static components = { TagsList };
static template = xml`<TagsList tags="tags" />`;
setup() {
this.tags = [
{
id: "tag1",
text: "Earth",
img: "fake/url",
},
{
colorIndex: 1,
id: "tag2",
text: "Wind",
img: "fake/url",
onDelete: () => {},
},
];
}
}
await mountWithCleanup(Parent);
expect(".o_tag").toHaveCount(2);
expect(".o_tag:nth-of-type(1) .o_avatar_backdrop").toHaveCount(0);
expect(".o_tag:nth-of-type(2) .o_avatar_backdrop").toHaveCount(1);
});

View file

@ -0,0 +1,372 @@
import { test, expect } from "@odoo/hoot";
import { applyInheritance } from "@web/core/template_inheritance";
import { serverState } from "@web/../tests/web_test_helpers";
const parser = new DOMParser();
const serializer = new XMLSerializer();
function _applyInheritance(arch, inherits) {
const archXmlDoc = parser.parseFromString(arch, "text/xml");
const inheritsDoc = parser.parseFromString(inherits, "text/xml");
const modifiedTemplate = applyInheritance(
archXmlDoc.documentElement,
inheritsDoc.documentElement,
"test/url"
);
return serializer.serializeToString(modifiedTemplate);
}
test("no operation", async () => {
const arch = `<t t-name="web.A"> <div><h2>Title</h2>text</div> </t>`;
const operations = `<t/>`;
expect(_applyInheritance(arch, operations)).toBe(arch);
});
test("single operation: replace", async () => {
const toTest = [
{
arch: `<t t-name="web.A"> <div><h2>Title</h2>text</div> </t>`,
operations: `
<t>
<xpath expr="./div/h2" position="replace"><h3>Other title</h3></xpath>
</t>`,
result: `<t t-name="web.A"> <div><h3>Other title</h3>text</div> </t>`,
// TODO check if text should be there? (I think there is a bug in python code)
},
];
for (const { arch, operations, result } of toTest) {
expect(_applyInheritance(arch, operations)).toBe(result);
}
});
test("single operation: replace (debug mode)", async () => {
serverState.debug = "1";
const toTest = [
{
arch: `<t t-name="web.A"> <div><h2>Title</h2>text</div> </t>`,
operations: `
<t>
<xpath expr="./div/h2" position="replace"><h3>Other title</h3></xpath>
</t>`,
result: `<t t-name="web.A"> <div><!-- From file: test/url ; expr="./div/h2" ; position="replace" --><h3>Other title</h3>text</div> </t>`,
},
];
for (const { arch, operations, result } of toTest) {
expect(_applyInheritance(arch, operations)).toBe(result);
}
});
test("single operation: replace root (and use a $0)", async () => {
const toTest = [
{
arch: `<t t-name="web.A"> <div>I was petrified</div> </t>`,
operations: `
<t>
<xpath expr="." position="replace"><div>At first I was afraid</div>$0</xpath>
</t>`,
result: `<div t-name="web.A">At first I was afraid</div>`,
// in outer mode with no parent only first child of operation is kept
},
{
arch: `<t t-name="web.A"> <div>I was petrified</div> </t>`,
operations: `
<t>
<xpath expr="." position="replace"> <div>$0</div><div>At first I was afraid</div> </xpath>
</t>`,
result: `<div t-name="web.A"><t t-name="web.A"> <div>I was petrified</div> </t></div>`,
},
{
arch: `<t t-name="web.A"> <div>I was petrified</div> </t>`,
operations: `
<t>
<xpath expr="." position="replace"> <t><t t-if="cond"><div>At first I was afraid</div></t><t t-else="">$0</t></t> </xpath>
</t>`,
result: `<t t-name="web.A"><t t-if="cond"><div>At first I was afraid</div></t><t t-else=""><t t-name="web.A"> <div>I was petrified</div> </t></t></t>`,
},
{
arch: `<form t-name="template_1_1" random-attr="gloria"> <div>At first I was afraid</div> <form>Inner Form</form> </form>`,
operations: `
<t>
<xpath expr="//form" position="replace">
<div> Form replacer </div>
</xpath>
</t>`,
result: `<div t-name="template_1_1"> Form replacer </div>`,
},
{
arch: `<form t-name="template_1_1" random-attr="gloria"> <div>At first I was afraid</div> </form>`,
operations: `
<t t-name="template_1_2">
<xpath expr="." position="replace">
<div overriden-attr="overriden">And I grew strong</div>
</xpath>
</t>
`,
result: `<div overriden-attr="overriden" t-name="template_1_1">And I grew strong</div>`,
},
];
for (const { arch, operations, result } of toTest) {
expect(_applyInheritance(arch, operations)).toBe(result);
}
});
test("single operation: replace (mode inner)", async () => {
const toTest = [
{
arch: `<t t-name="web.A"> <div> A <span/> B <span/> C </div> </t>`,
operations: `
<t>
<xpath expr="./div" position="replace" mode="inner"> E <div/> F <span attr1="12"/> </xpath>
</t>`,
result: `<t t-name="web.A"> <div> E <div/> F <span attr1="12"/> </div> </t>`,
},
];
for (const { arch, operations, result } of toTest) {
expect(_applyInheritance(arch, operations)).toBe(result);
}
});
test("single operation: replace (mode inner) (debug mode)", async () => {
serverState.debug = "1";
const toTest = [
{
arch: `<t t-name="web.A"> <div> A <span/> B <span/> C </div> </t>`,
operations: `
<t>
<xpath expr="./div" position="replace" mode="inner"> E <div/> F <span attr1="12"/> </xpath>
</t>`,
result: `<t t-name="web.A"> <div><!-- From file: test/url ; expr="./div" ; position="replace" ; mode="inner" --> E <div/> F <span attr1="12"/> </div> </t>`,
},
];
for (const { arch, operations, result } of toTest) {
expect(_applyInheritance(arch, operations, "/test/url")).toBe(result);
}
});
test("single operation: before", async () => {
const toTest = [
{
arch: `<t t-name="web.A"> <div>AAB is the best<h2>Title</h2>text</div> </t>`,
operations: `
<t>
<xpath expr="./div/h2" position="before"> <h3>Other title</h3>Yooplahoo!<h4>Yet another title</h4> </xpath>
</t>`,
result: `<t t-name="web.A"> <div>AAB is the best <h3>Other title</h3>Yooplahoo!<h4>Yet another title</h4> <h2>Title</h2>text</div> </t>`,
},
{
arch: `<t t-name="web.A"> <div>AAB is the best<h2>Title</h2><div><span>Ola</span></div></div> </t>`,
operations: `
<t>
<xpath expr="./div/h2" position="before"> <xpath expr="./div/div/span" position="move" /> </xpath>
</t>`,
result: `<t t-name="web.A"> <div>AAB is the best <span>Ola</span> <h2>Title</h2><div/></div> </t>`,
},
{
arch: `<t t-name="web.A"> a <div/> </t>`,
operations: `
<t>
<xpath expr="./div" position="before"> 4 </xpath>
</t>`,
result: `<t t-name="web.A"> a 4 <div/> </t>`,
},
];
for (const { arch, operations, result } of toTest) {
expect(_applyInheritance(arch, operations)).toBe(result);
}
});
test("single operation: inside", async () => {
const toTest = [
{
arch: `<t t-name="web.A"> <div>AAB is the best <h2>Title</h2> <div/> </div> </t>`,
operations: `
<t>
<xpath expr="./div/div" position="inside"> Hop! <xpath expr="./div/h2" position="move" /> <span>Yellow</span> </xpath>
</t>`,
result: `<t t-name="web.A"> <div>AAB is the best <div> Hop! <h2>Title</h2> <span>Yellow</span> </div> </div> </t>`,
},
{
arch: `<t><div/></t>`,
operations: `
<t>
<xpath expr="./div" position="inside">4</xpath>
</t>`,
result: `<t><div>4</div></t>`,
},
{
arch: `<t><div>\na \n </div></t>`,
operations: `
<t>
<xpath expr="./div" position="inside">4</xpath>
</t>`,
result: `<t><div>\na4</div></t>`,
},
{
arch: `<t>a<div></div><span/></t>`,
operations: `
<t>
<xpath expr="./div" position="inside"><span/></xpath>
</t>`,
result: `<t>a<div><span/></div><span/></t>`,
},
];
for (const { arch, operations, result } of toTest) {
expect(_applyInheritance(arch, operations)).toBe(result);
}
});
test("single operation: after", async () => {
const toTest = [
{
arch: `<t t-name="web.A"> <div> AAB is the best <h2>Title</h2> <div id="1"/> <div id="2"/> </div> </t>`,
operations: `
<t>
<xpath expr="./div/h2" position="after"> Hop! <xpath expr="./div/div[2]" position="move" /> <span>Yellow</span> </xpath>
</t>`,
result: `<t t-name="web.A"> <div> AAB is the best <h2>Title</h2> Hop! <div id="2"/> <span>Yellow</span> <div id="1"/> </div> </t>`,
},
{
arch: `<t t-name="web.A"> <div/>a </t>`,
operations: `
<t>
<xpath expr="./div" position="after">4</xpath>
</t>`,
result: `<t t-name="web.A"> <div/>4a </t>`,
},
];
for (const { arch, operations, result } of toTest) {
expect(_applyInheritance(arch, operations)).toBe(result);
}
});
test("single operation: attributes", async (assert) => {
const toTest = [
{
arch: `<t t-name="web.A"> <div attr1="12" attr2="a b" attr3="to remove" /> </t>`,
operations: `
<t>
<xpath expr="./div" position="attributes">
<attribute name="attr1">45</attribute>
<attribute name="attr3"></attribute>
<attribute name="attr2" add="c" separator=" "></attribute>
<attribute name="attr2" remove="a" separator=" "></attribute>
<attribute name="attr4">new</attribute>
</xpath>
</t>`,
result: `<t t-name="web.A"> <div attr1="45" attr2="b c" attr4="new"/> </t>`,
},
{
arch: `<t t-name="web.A"> <div><a href="1"/><div><a href="2"/></div></div> </t>`,
operations: `
<t>
<xpath expr="//a[@href='2']" position="attributes">
<attribute name="found">1</attribute>
</xpath>
</t>`,
result: `<t t-name="web.A"> <div><a href="1"/><div><a href="2" found="1"/></div></div> </t>`,
},
];
for (const { arch, operations, result } of toTest) {
expect(_applyInheritance(arch, operations)).toBe(result);
}
});
test("single operation: attributes (debug mode)", async () => {
serverState.debug = "1";
const toTest = [
{
arch: `<t t-name="web.A"> <div attr1="12" attr2="a b" attr3="to remove" /> </t>`,
operations: `
<t>
<xpath expr="./div" position="attributes">
<attribute name="attr1">45</attribute>
<attribute name="attr3"></attribute>
<attribute name="attr2" add="c" separator=" "></attribute>
<attribute name="attr2" remove="a" separator=" "></attribute>
<attribute name="attr4">new</attribute>
</xpath>
</t>`,
result: `<t t-name="web.A"> <!-- From file: test/url ; expr="./div" ; position="attributes" --><div attr1="45" attr2="b c" attr4="new"/> </t>`,
},
];
for (const { arch, operations, result } of toTest) {
expect(_applyInheritance(arch, operations)).toBe(result);
}
});
test("xpath with hasclass", async () => {
const toTest = [
{
arch: `<t><div class="abc"/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc')]" position="replace"></xpath></t>`,
result: `<t/>`,
},
{
arch: `<t><div class="abc "/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc')]" position="replace"></xpath></t>`,
result: `<t/>`,
},
{
arch: `<t><div class=" abc"/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc')]" position="replace"></xpath></t>`,
result: `<t/>`,
},
{
arch: `<t><div class=" abc "/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc')]" position="replace"></xpath></t>`,
result: `<t/>`,
},
{
arch: `<t><div class="d abc e"/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc')]" position="replace"></xpath></t>`,
result: `<t/>`,
},
{
arch: `<t><div class="d abc"/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc')]" position="replace"></xpath></t>`,
result: `<t/>`,
},
{
arch: `<t><div class="abc d"/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc')]" position="replace"></xpath></t>`,
result: `<t/>`,
},
{
arch: `<t><div class="abcd e abc"/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc')]" position="replace"></xpath></t>`,
result: `<t/>`,
},
{
arch: `<t><div class="abcd e abc"/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc', 'abcd' )]" position="replace"></xpath></t>`,
result: `<t/>`,
},
{
arch: `<t><div class="abcd e abc"/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc') and hasclass('abcd')]" position="replace"></xpath></t>`,
result: `<t/>`,
},
{
arch: `<t><div class="abcd"/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc')]" position="replace"></xpath></t>`,
isError: true,
},
{
arch: `<t><div class="dabc"/></t>`,
operations: `<t><xpath expr="./div[hasclass('abc')]" position="replace"></xpath></t>`,
isError: true,
},
{
arch: `<t><div class="dabc"/></t>`,
operations: `<t><xpath expr="./div[ends-with(@class, 'bc')]" position="replace"></xpath></t>`,
isError: true,
},
];
for (const { arch, operations, result, isError } of toTest) {
if (isError) {
expect(() => _applyInheritance(arch, operations)).toThrow();
} else {
expect(_applyInheritance(arch, operations)).toBe(result);
}
}
});

View file

@ -0,0 +1,388 @@
import { expect, test } from "@odoo/hoot";
import { click, drag, hover, leave, pointerDown, pointerUp, queryOne } from "@odoo/hoot-dom";
import { advanceTime, animationFrame, mockTouch, runAllTimers } from "@odoo/hoot-mock";
import { Component, useState, xml } from "@odoo/owl";
import { makeMockEnv, mockService, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { popoverService } from "@web/core/popover/popover_service";
import { OPEN_DELAY, SHOW_AFTER_DELAY } from "@web/core/tooltip/tooltip_service";
test.tags("desktop");
test("basic rendering", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`<button class="mybtn" data-tooltip="hello">Action</button>`;
}
await mountWithCleanup(MyComponent);
expect(".o_popover").toHaveCount(0);
await hover(".mybtn");
expect(".o_popover").toHaveCount(0);
await runAllTimers();
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("hello");
await leave();
await animationFrame();
expect(".o_popover").toHaveCount(0);
});
test.tags("desktop");
test("basic rendering 2", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`<span data-tooltip="hello" class="outer_span"><span class="inner_span">Action</span></span>`;
}
await mountWithCleanup(MyComponent);
expect(".o_popover").toHaveCount(0);
await hover(".inner_span");
expect(".o_popover").toHaveCount(0);
await runAllTimers();
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("hello");
await hover(".outer_span");
await runAllTimers();
expect(".o_popover").toHaveCount(1);
await leave();
await animationFrame();
expect(".o_popover").toHaveCount(0);
});
test.tags("desktop");
test("remove element with opened tooltip", async () => {
let compState;
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<div>
<button t-if="state.visible" data-tooltip="hello">Action</button>
</div>`;
setup() {
this.state = useState({ visible: true });
compState = this.state;
}
}
await mountWithCleanup(MyComponent);
expect("button").toHaveCount(1);
expect(".o_popover").toHaveCount(0);
await hover("button");
await runAllTimers();
expect(".o_popover").toHaveCount(1);
compState.visible = false;
await animationFrame();
expect("button").toHaveCount(0);
await runAllTimers();
expect(".o_popover").toHaveCount(0);
});
test.tags("desktop");
test("rendering with several tooltips", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<div>
<button class="button_1" data-tooltip="tooltip 1">Action 1</button>
<button class="button_2" data-tooltip="tooltip 2">Action 2</button>
</div>`;
}
await mountWithCleanup(MyComponent);
expect(".o_popover").toHaveCount(0);
await hover("button.button_1");
await runAllTimers();
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("tooltip 1");
await hover("button.button_2");
await runAllTimers();
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("tooltip 2");
});
test.tags("desktop");
test("positioning", async () => {
mockService("popover", (...kargs) => {
const popover = popoverService.start(...kargs);
return {
add(...args) {
const { position } = args[3];
if (position) {
expect.step(`popover added with position: ${position}`);
} else {
expect.step(`popover added with default positioning`);
}
return popover.add(...args);
},
};
});
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<div style="height: 400px; padding: 40px">
<button class="default" data-tooltip="default">Default</button>
<button class="top" data-tooltip="top" data-tooltip-position="top">Top</button>
<button class="right" data-tooltip="right" data-tooltip-position="right">Right</button>
<button class="bottom" data-tooltip="bottom" data-tooltip-position="bottom">Bottom</button>
<button class="left" data-tooltip="left" data-tooltip-position="left">Left</button>
</div>`;
}
await mountWithCleanup(MyComponent);
// default
await hover("button.default");
await runAllTimers();
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("default");
expect.verifySteps(["popover added with default positioning"]);
// top
await hover("button.top");
await runAllTimers();
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("top");
expect.verifySteps(["popover added with position: top"]);
// right
await hover("button.right");
await runAllTimers();
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("right");
expect.verifySteps(["popover added with position: right"]);
// bottom
await hover("button.bottom");
await runAllTimers();
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("bottom");
expect.verifySteps(["popover added with position: bottom"]);
// left
await hover("button.left");
await runAllTimers();
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("left");
expect.verifySteps(["popover added with position: left"]);
});
test.tags("desktop");
test("tooltip with a template, no info", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<button data-tooltip-template="my_tooltip_template">Action</button>
`;
}
await makeMockEnv({ tooltip_text: "tooltip" });
await mountWithCleanup(MyComponent, {
templates: {
my_tooltip_template: /* xml */ `<i t-esc='env.tooltip_text'/>`,
},
});
expect(".o-tooltip").toHaveCount(0);
await hover("button");
await runAllTimers();
expect(".o-tooltip").toHaveCount(1);
expect(".o-tooltip").toHaveInnerHTML("<i>tooltip</i>");
});
test.tags("desktop");
test("tooltip with a template and info", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<button
data-tooltip-template="my_tooltip_template"
t-att-data-tooltip-info="info">
Action
</button>
`;
get info() {
return JSON.stringify({ x: 3, y: "abc" });
}
}
await mountWithCleanup(MyComponent, {
templates: {
my_tooltip_template: /* xml */ `
<ul>
<li>X: <t t-esc="x"/></li>
<li>Y: <t t-esc="y"/></li>
</ul>
`,
},
});
expect(".o-tooltip").toHaveCount(0);
await hover("button");
await runAllTimers();
expect(".o-tooltip").toHaveCount(1);
expect(".o-tooltip").toHaveInnerHTML("<ul><li>X: 3</li><li>Y: abc</li></ul>");
});
test.tags("desktop");
test("empty tooltip, no template", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`<button t-att-data-tooltip="tooltip">Action</button>`;
get tooltip() {
return "";
}
}
await mountWithCleanup(MyComponent);
expect(".o-tooltip").toHaveCount(0);
await hover("button");
await runAllTimers();
expect(".o-tooltip").toHaveCount(0);
});
test.tags("desktop");
test("tooltip with a delay", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`<button class="myBtn" data-tooltip="'helpful tooltip'" data-tooltip-delay="2000">Action</button>`;
}
await mountWithCleanup(MyComponent);
expect(".o-tooltip").toHaveCount(0);
await hover("button.myBtn");
await advanceTime(OPEN_DELAY);
expect(".o-tooltip").toHaveCount(0);
await advanceTime(2000 - OPEN_DELAY);
expect(".o-tooltip").toHaveCount(1);
});
test.tags("desktop");
test("tooltip does not crash with disappearing target", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`<button class="mybtn" data-tooltip="hello">Action</button>`;
}
await mountWithCleanup(MyComponent);
expect(".o_popover").toHaveCount(0);
await hover(".mybtn");
await animationFrame();
expect(".o_popover").toHaveCount(0);
// the element disappeared from the DOM during the setTimeout
queryOne(".mybtn").remove();
await runAllTimers();
expect(".o_popover").toHaveCount(0);
});
test.tags("desktop");
test("tooltip using touch enabled device", async () => {
mockTouch(true);
class MyComponent extends Component {
static props = ["*"];
static template = xml`<button class="mybtn" data-tooltip="hello">Action</button>`;
}
await mountWithCleanup(MyComponent);
expect(".o_popover").toHaveCount(0);
await drag(".mybtn");
await animationFrame();
expect(".o_popover").toHaveCount(0);
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("hello");
await runAllTimers();
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("hello");
});
test.tags("mobile");
test("touch rendering - hold-to-show", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`<button data-tooltip="hello">Action</button>`;
}
await mountWithCleanup(MyComponent);
expect(".o_popover").toHaveCount(0);
await pointerDown("button");
await animationFrame();
expect(".o_popover").toHaveCount(0);
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("hello");
await pointerUp("button");
await animationFrame();
expect(".o_popover").toHaveCount(1);
await pointerDown(document.body);
await animationFrame();
expect(".o_popover").toHaveCount(0);
});
test.tags("mobile");
test("touch rendering - tap-to-show", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`<button data-tooltip="hello" data-tooltip-touch-tap-to-show="true">Action</button>`;
}
await mountWithCleanup(MyComponent);
expect(".o_popover").toHaveCount(0);
await pointerDown("button[data-tooltip]");
await animationFrame();
expect(".o_popover").toHaveCount(0);
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(".o_popover").toHaveCount(1);
expect(".o_popover").toHaveText("hello");
await pointerUp("button");
await animationFrame();
expect(".o_popover").toHaveCount(1);
await runAllTimers();
expect(".o_popover").toHaveCount(1);
// The tooltip should be closed if you click on the button itself
await click("button[data-tooltip]");
await animationFrame();
expect(".o_popover").toHaveCount(0);
// Reopen it
await pointerDown("button[data-tooltip]");
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(".o_popover").toHaveCount(1);
// The tooltip should be also closed if you click anywhere else
await pointerDown(document.body);
await animationFrame();
expect(".o_popover").toHaveCount(0);
});

View file

@ -0,0 +1,150 @@
import { test, expect } from "@odoo/hoot";
import { Transition, useTransition, config as transitionConfig } from "@web/core/transition";
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { Component, xml, useState } from "@odoo/owl";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
test("useTransition hook (default params)", async () => {
patchWithCleanup(transitionConfig, {
disabled: false,
});
class Parent extends Component {
static template = xml`<div t-if="transition.shouldMount" t-att-class="transition.className"/>`;
static props = ["*"];
setup() {
this.transition = useTransition({
name: "test",
onLeave: () => expect.step("leave"),
});
}
}
// noMainContainer, because the await for the mount of the main container
// will already change the transition
const parent = await mountWithCleanup(Parent, { noMainContainer: true });
expect(".test.test-enter-active:not(.test-enter)").toHaveCount(1);
parent.transition.shouldMount = false;
await animationFrame();
// Leaving: -leave but not -enter-active
expect(".test.test-leave:not(.test-enter-active)").toHaveCount(1);
expect.verifySteps([]);
await runAllTimers();
expect.verifySteps(["leave"]);
await animationFrame();
expect(".test").toHaveCount(0);
});
test("useTransition hook (initially visible and immediate=true)", async () => {
patchWithCleanup(transitionConfig, {
disabled: false,
});
class Parent extends Component {
static template = xml`<div t-if="transition.shouldMount" t-att-class="transition.className"/>`;
static props = ["*"];
setup() {
this.transition = useTransition({
name: "test",
immediate: true,
onLeave: () => expect.step("leave"),
});
}
}
// noMainContainer, because the await for the mount of the main container
// will already change the transition
const parent = await mountWithCleanup(Parent, { noMainContainer: true });
// Mounted with -enter but not -enter-active
expect(".test.test-enter:not(.test-enter-active)").toHaveCount(1);
await animationFrame();
// No longer has -enter class but now has -enter-active
expect(".test.test-enter-active:not(.test-enter)").toHaveCount(1);
parent.transition.shouldMount = false;
await animationFrame();
// Leaving: -leave but not -enter-active
expect(".test.test-leave:not(.test-enter-active)").toHaveCount(1);
expect.verifySteps([]);
await runAllTimers();
expect.verifySteps(["leave"]);
await animationFrame();
expect(".test").toHaveCount(0);
});
test("useTransition hook (initially not visible)", async () => {
patchWithCleanup(transitionConfig, {
disabled: false,
});
class Parent extends Component {
static template = xml`<div t-if="transition.shouldMount" t-att-class="transition.className"/>`;
static props = ["*"];
setup() {
this.transition = useTransition({
name: "test",
initialVisibility: false,
onLeave: () => expect.step("leave"),
});
}
}
// noMainContainer, because the await for the mount of the main container
// will already change the transition
const parent = await mountWithCleanup(Parent, { noMainContainer: true });
expect(".test").toHaveCount(0);
parent.transition.shouldMount = true;
await animationFrame();
// Leaving: -leave but not -enter-active
expect(".test.test-enter:not(.test-enter-active)").toHaveCount(1);
await animationFrame();
// No longer has -enter class but now has -enter-active
expect(".test.test-enter-active:not(.test-enter)").toHaveCount(1);
await runAllTimers();
expect.verifySteps([]);
await animationFrame();
});
test("Transition HOC", async () => {
patchWithCleanup(transitionConfig, {
disabled: false,
});
class Parent extends Component {
static template = xml`
<Transition name="'test'" visible="state.show" immediate="true" t-slot-scope="transition" onLeave="onLeave">
<div t-att-class="transition.className"/>
</Transition>
`;
static components = { Transition };
static props = ["*"];
setup() {
this.state = useState({ show: true });
}
onLeave() {
expect.step("leave");
}
}
// noMainContainer, because the await for the mount of the main container
// will already change the transition
const parent = await mountWithCleanup(Parent, { noMainContainer: true });
// Mounted with -enter but not -enter-active
expect(".test.test-enter:not(.test-enter-active)").toHaveCount(1);
await animationFrame();
// No longer has -enter class but now has -enter-active
expect(".test.test-enter-active:not(.test-enter)").toHaveCount(1);
parent.state.show = false;
await animationFrame();
// Leaving: -leave but not -enter-active
expect(".test.test-leave:not(.test-enter-active)").toHaveCount(1);
expect.verifySteps([]);
await runAllTimers();
expect.verifySteps(["leave"]);
await animationFrame();
expect(".test").toHaveCount(0);
});

View file

@ -0,0 +1,398 @@
import { queryAll, queryAllTexts, queryOne, queryText, queryValue } from "@odoo/hoot-dom";
import { contains, fields, models } from "@web/../tests/web_test_helpers";
/**
* @typedef {import("@odoo/hoot-dom").FillOptions} FillOptions
* @typedef {import("@odoo/hoot-dom").Target} Target
*/
function getValue(root) {
if (!root) {
return null;
}
const el = queryOne("input,select,span:not(.o_tag):not(.o_dropdown_button)", { root });
switch (el.tagName) {
case "INPUT":
return queryValue(el);
case "SELECT":
return el.options[el.selectedIndex].label;
default:
return queryText(el);
}
}
/**
* @param {Target} selector
* @param {number} [index]
* @param {Target} [root]
*/
function queryAt(selector, index, root) {
return queryAll(selector, { root }).at(index || 0);
}
export class Partner extends models.Model {
foo = fields.Char();
bar = fields.Boolean();
name = fields.Char({ string: "Partner Name" });
product_id = fields.Many2one({ relation: "product" });
int = fields.Integer();
date = fields.Date();
datetime = fields.Datetime();
json_field = fields.Json({ string: "Json Field" });
state = fields.Selection({
selection: [
["abc", "ABC"],
["def", "DEF"],
["ghi", "GHI"],
],
});
_records = [
{ id: 1, foo: "yop", bar: true, product_id: 37, name: "first record" },
{ id: 2, foo: "blip", bar: true, product_id: false, name: "second record" },
{ id: 4, foo: "abc", bar: false, product_id: 41, name: "aaa" },
];
}
export class Product extends models.Model {
name = fields.Char({ string: "Product Name" });
bar = fields.Boolean({ string: "Product Bar" });
team_id = fields.Many2one({
string: "Product Team",
relation: "team",
searchable: true,
});
_records = [
{ id: 37, name: "xphone" },
{ id: 41, name: "xpad" },
];
}
export class Team extends models.Model {
name = fields.Char({ string: "Team Name", searchable: true });
player_ids = fields.One2many({ relation: "player", string: "Players" });
_records = [
{ id: 1, display_name: "Mancester City" },
{ id: 2, display_name: "Arsenal" },
];
}
export class Player extends models.Model {
name = fields.Char({ string: "Player Name", searchable: true });
country_id = fields.Many2one({ string: "Country", relation: "country" });
_records = [
{ id: 1, name: "Kevin De Bruyne" },
{ id: 2, name: "Jeremy Doku" },
];
}
export class Country extends models.Model {
foo = fields.Char();
stage_id = fields.Many2one({ relation: "stage" });
}
export class Stage extends models.Model {
bar = fields.Boolean();
}
export const SELECTORS = {
node: ".o_tree_editor_node",
row: ".o_tree_editor_row",
tree: ".o_tree_editor > .o_tree_editor_node",
connector: ".o_tree_editor_connector",
condition: ".o_tree_editor_condition",
addNewRule: ".o_tree_editor_row > a",
buttonAddNewRule: ".o_tree_editor_node_control_panel > button:nth-child(1)",
buttonAddBranch: ".o_tree_editor_node_control_panel > button:nth-child(2)",
buttonDeleteNode: ".o_tree_editor_node_control_panel > button:nth-child(3)",
pathEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(1)",
operatorEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(2)",
valueEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(3)",
editor: ".o_tree_editor_editor",
clearNotSupported: ".o_input .fa-times",
tag: ".o_input .o_tag",
toggleArchive: ".form-switch",
complexCondition: ".o_tree_editor_complex_condition",
complexConditionInput: ".o_tree_editor_complex_condition input",
};
const CHILD_SELECTOR = ["connector", "condition", "complexCondition"]
.map((k) => SELECTORS[k])
.join(",");
export function getTreeEditorContent(options = {}) {
const content = [];
const nodes = queryAll(SELECTORS.node);
const mapping = new Map();
for (const node of nodes) {
const parent = node.parentElement.closest(SELECTORS.node);
const level = parent ? mapping.get(parent) + 1 : 0;
mapping.set(node, level);
const nodeValue = { level };
const associatedNode = node.querySelector(CHILD_SELECTOR);
const className = associatedNode.className;
if (className.includes("connector")) {
nodeValue.value = getCurrentConnector(0, node);
} else if (className.includes("complex_condition")) {
nodeValue.value = getCurrentComplexCondition(0, node);
} else {
nodeValue.value = getCurrentCondition(0, node);
}
if (options.node) {
nodeValue.node = node;
}
content.push(nodeValue);
}
return content;
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export function getCurrentPath(index, target) {
const pathEditor = queryAt(SELECTORS.pathEditor, index, target);
if (pathEditor) {
if (pathEditor.querySelector(".o_model_field_selector")) {
return getModelFieldSelectorValues(pathEditor).join(" > ");
}
return queryText(pathEditor);
}
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export function getCurrentOperator(index, target) {
const operatorEditor = queryAt(SELECTORS.operatorEditor, index, target);
return getValue(operatorEditor);
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export function getCurrentValue(index, target) {
const valueEditor = queryAt(SELECTORS.valueEditor, index, target);
const value = getValue(valueEditor);
if (valueEditor) {
const texts = queryAllTexts(`.o_tag`, { root: valueEditor });
if (texts.length) {
if (value) {
texts.push(value);
}
return texts.join(" ");
}
}
return value;
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export function getOperatorOptions(index, target) {
const el = queryAt(SELECTORS.operatorEditor, index, target);
if (el) {
return queryAll(`select:only option`, { root: el }).map((o) => o.label);
}
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export function getValueOptions(index, target) {
const el = queryAt(SELECTORS.valueEditor, index, target);
if (el) {
return queryAll(`select:only option`, { root: el }).map((o) => o.label);
}
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
function getCurrentComplexCondition(index, target) {
const input = queryAt(SELECTORS.complexConditionInput, index, target);
return input?.value;
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export function getConditionText(index, target) {
const condition = queryAt(SELECTORS.condition, index, target);
return queryText(condition).replace(/\n/g, " ");
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
function getCurrentCondition(index, target) {
const values = [getCurrentPath(index, target), getCurrentOperator(index, target)];
const valueEditor = queryAt(SELECTORS.valueEditor, index, target);
if (valueEditor) {
values.push(getCurrentValue(index, target));
}
return values;
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
function getCurrentConnector(index, target) {
const connectorText = queryAllTexts(
`${SELECTORS.connector} .dropdown-toggle, ${SELECTORS.connector} > span:nth-child(2), ${SELECTORS.connector} > span > strong`,
{ root: target }
).at(index);
return connectorText.includes("all") ? "all" : connectorText;
}
////////////////////////////////////////////////////////////////////////////////
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export function isNotSupportedPath(index, target) {
const pathEditor = queryAt(SELECTORS.pathEditor, index, target);
return Boolean(queryOne(SELECTORS.clearNotSupported, { root: pathEditor }));
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export function isNotSupportedOperator(index, target) {
const operatorEditor = queryAt(SELECTORS.operatorEditor, index, target);
return Boolean(queryOne(SELECTORS.clearNotSupported, { root: operatorEditor }));
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export function isNotSupportedValue(index, target) {
const valueEditor = queryAt(SELECTORS.valueEditor, index, target);
return Boolean(queryOne(SELECTORS.clearNotSupported, { root: valueEditor }));
}
////////////////////////////////////////////////////////////////////////////////
/**
* @param {any} operator
* @param {number} [index=0]
* @param {Target} [target]
*/
export async function selectOperator(operator, index, target) {
await contains(`${SELECTORS.operatorEditor}:eq(${index || 0}) select`, { root: target }).select(
JSON.stringify(operator)
);
}
/**
* @param {any} value
* @param {number} [index=0]
* @param {Target} [target]
*/
export async function selectValue(value, index, target) {
await contains(`${SELECTORS.valueEditor}:eq(${index || 0}) select`, { root: target }).select(
JSON.stringify(value)
);
}
/**
* @param {any} value
* @param {FillOptions} [options]
* @param {number} [index=0]
* @param {Target} [target]
*/
export async function editValue(value, options, index, target) {
await contains(`${SELECTORS.valueEditor}:eq(${index || 0}) input`, { root: target }).edit(
value,
options
);
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export async function clickOnButtonAddNewRule(index, target) {
await contains(queryAt(SELECTORS.buttonAddNewRule, index, target)).click();
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export async function clickOnButtonAddBranch(index, target) {
await contains(queryAt(SELECTORS.buttonAddBranch, index, target)).click();
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export async function clickOnButtonDeleteNode(index, target) {
await contains(queryAt(SELECTORS.buttonDeleteNode, index, target)).click();
}
/**
* @param {number} [index=0]
* @param {Target} [target]
*/
export async function clearNotSupported(index, target) {
await contains(queryAt(SELECTORS.clearNotSupported, index, target)).click();
}
export async function addNewRule() {
await contains(SELECTORS.addNewRule).click();
}
export async function toggleArchive() {
await contains(SELECTORS.toggleArchive).click();
}
////////////////////////////////////////////////////////////////////////////////
/**
* @param {number} [index=0]
*/
export async function openModelFieldSelectorPopover(index = 0) {
await contains(`.o_model_field_selector:eq(${index})`).click();
}
export function getModelFieldSelectorValues(root) {
return queryAllTexts("span.o_model_field_selector_chain_part", { root });
}
export function getDisplayedFieldNames() {
return queryAllTexts(".o_model_field_selector_popover_item_name");
}
export function getTitle() {
return queryText(".o_model_field_selector_popover .o_model_field_selector_popover_title");
}
export async function clickPrev() {
await contains(".o_model_field_selector_popover_prev_page").click();
}
/**
* @param {number} [index=0]
*/
export async function followRelation(index = 0) {
await contains(`.o_model_field_selector_popover_item_relation:eq(${index})`).click();
}
export function getFocusedFieldName() {
return queryText(".o_model_field_selector_popover_item.active");
}

View file

@ -0,0 +1,236 @@
import { describe, expect, test } from "@odoo/hoot";
import { press, queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, useState, xml } from "@odoo/owl";
import { getService, mountWithCleanup } from "../web_test_helpers";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { useActiveElement } from "@web/core/ui/ui_service";
import { useAutofocus } from "@web/core/utils/hooks";
describe.current.tags("desktop");
test("block and unblock once ui with ui service", async () => {
await mountWithCleanup(MainComponentsContainer);
expect(".o_blockUI").toHaveCount(0);
getService("ui").block();
await animationFrame();
expect(".o_blockUI").toHaveCount(1);
getService("ui").unblock();
await animationFrame();
expect(".o_blockUI").toHaveCount(0);
});
test("use block and unblock several times to block ui with ui service", async () => {
await mountWithCleanup(MainComponentsContainer);
expect(".o_blockUI").toHaveCount(0);
getService("ui").block();
getService("ui").block();
getService("ui").block();
await animationFrame();
expect(".o_blockUI").toHaveCount(1);
getService("ui").unblock();
getService("ui").unblock();
await animationFrame();
expect(".o_blockUI").toHaveCount(1);
getService("ui").unblock();
await animationFrame();
expect(".o_blockUI").toHaveCount(0);
});
test("a component can be the UI active element: simple usage", async () => {
class MyComponent extends Component {
static template = xml`
<div>
<h1>My Component</h1>
<div t-if="hasRef" id="owner" t-ref="delegatedRef">
<input type="text"/>
</div>
</div>
`;
static props = ["*"];
setup() {
useActiveElement("delegatedRef");
this.hasRef = true;
}
}
const comp = await mountWithCleanup(MyComponent);
expect(getService("ui").activeElement).toBe(queryOne("#owner"));
expect("#owner input").toBeFocused();
comp.hasRef = false;
comp.render();
await animationFrame();
expect(getService("ui").activeElement).toBe(document);
expect(document.body).toBeFocused();
});
test("UI active element: trap focus", async () => {
class MyComponent extends Component {
static template = xml`
<div>
<h1>My Component</h1>
<input type="text" placeholder="outerUIActiveElement"/>
<div t-ref="delegatedRef">
<input type="text" placeholder="withFocus"/>
</div>
</div>
`;
static props = ["*"];
setup() {
useActiveElement("delegatedRef");
}
}
await mountWithCleanup(MyComponent);
expect("input[placeholder=withFocus]").toBeFocused();
let [firstEvent] = await press("Tab", { shiftKey: false });
await animationFrame();
expect(firstEvent.defaultPrevented).toBe(true);
expect("input[placeholder=withFocus]").toBeFocused();
[firstEvent] = await press("Tab", { shiftKey: true });
await animationFrame();
expect(firstEvent.defaultPrevented).toBe(true);
expect("input[placeholder=withFocus]").toBeFocused();
});
test("UI active element: trap focus - default focus with autofocus", async () => {
class MyComponent extends Component {
static template = xml`
<div>
<h1>My Component</h1>
<input type="text" placeholder="outerUIActiveElement"/>
<div t-ref="delegatedRef">
<input type="text" placeholder="withoutFocus"/>
<input type="text" t-ref="autofocus" placeholder="withAutoFocus"/>
</div>
</div>
`;
static props = ["*"];
setup() {
useActiveElement("delegatedRef");
useAutofocus();
}
}
await mountWithCleanup(MyComponent);
expect("input[placeholder=withAutoFocus]").toBeFocused();
let [firstEvent] = await press("Tab", { shiftKey: false });
await animationFrame();
expect(firstEvent.defaultPrevented).toBe(true);
expect("input[placeholder=withoutFocus]").toBeFocused();
[firstEvent] = await press("Tab", { shiftKey: true });
await animationFrame();
expect(firstEvent.defaultPrevented).toBe(true);
expect("input[placeholder=withAutoFocus]").toBeFocused();
[firstEvent] = await press("Tab", { shiftKey: true });
await animationFrame();
expect(firstEvent.defaultPrevented).toBe(false);
});
test("do not become UI active element if no element to focus", async () => {
class MyComponent extends Component {
static template = xml`
<div>
<h1>My Component</h1>
<input type="text" placeholder="outerUIActiveElement"/>
<div id="idActiveElement" t-ref="delegatedRef">
<div>
<span> No focus element </span>
</div>
</div>
</div>
`;
static props = ["*"];
setup() {
useActiveElement("delegatedRef");
}
}
await mountWithCleanup(MyComponent);
expect(getService("ui").activeElement).toBe(document);
});
test("UI active element: trap focus - first or last tabable changes", async () => {
class MyComponent extends Component {
static template = xml`
<div>
<h1>My Component</h1>
<input type="text" name="outer"/>
<div id="idActiveElement" t-ref="delegatedRef">
<div>
<input type="text" name="a" t-if="show.a"/>
<input type="text" name="b"/>
<input type="text" name="c" t-if="show.c"/>
</div>
</div>
</div>
`;
static props = ["*"];
setup() {
this.show = useState({ a: true, c: false });
useActiveElement("delegatedRef");
}
}
const comp = await mountWithCleanup(MyComponent);
expect("input[name=a]").toBeFocused();
let [firstEvent] = await press("Tab", { shiftKey: true });
await animationFrame();
expect(firstEvent.defaultPrevented).toBe(true);
expect("input[name=b]").toBeFocused();
comp.show.a = false;
comp.show.c = true;
await animationFrame();
expect("input[name=b]").toBeFocused();
[firstEvent] = await press("Tab", { shiftKey: true });
await animationFrame();
expect(firstEvent.defaultPrevented).toBe(true);
expect("input[name=c]").toBeFocused();
});
test("UI active element: trap focus is not bypassed using invisible elements", async () => {
class MyComponent extends Component {
static template = xml`
<div>
<h1>My Component</h1>
<input type="text" placeholder="outerUIActiveElement"/>
<div t-ref="delegatedRef">
<input type="text" placeholder="withFocus"/>
<input class="d-none" type="text" placeholder="withFocusNotDisplayed"/>
<div class="d-none">
<input type="text" placeholder="withFocusNotDisplayedToo"/>
</div>
</div>
</div>
`;
static props = ["*"];
setup() {
useActiveElement("delegatedRef");
}
}
await mountWithCleanup(MyComponent);
expect("input[placeholder=withFocus]").toBeFocused();
let [firstEvent] = await press("Tab", { shiftKey: false });
await animationFrame();
expect(firstEvent.defaultPrevented).toBe(true);
expect("input[placeholder=withFocus]").toBeFocused();
[firstEvent] = await press("Tab", { shiftKey: true });
await animationFrame();
expect(firstEvent.defaultPrevented).toBe(true);
expect("input[placeholder=withFocus]").toBeFocused();
});

View file

@ -0,0 +1,39 @@
import { test, expect, describe } from "@odoo/hoot";
import { _makeUser, user } from "@web/core/user";
import { makeMockEnv, onRpc, patchWithCleanup, serverState } from "@web/../tests/web_test_helpers";
describe.current.tags("headless");
test("successive calls to hasGroup", async () => {
serverState.uid = 7;
await makeMockEnv();
const groups = ["x"];
onRpc("has_group", (args) => {
expect.step(`${args.model}/${args.method}/${args.args[1]}`);
return groups.includes(args.args[1]);
});
const hasGroupX = await user.hasGroup("x");
const hasGroupY = await user.hasGroup("y");
expect(hasGroupX).toBe(true);
expect(hasGroupY).toBe(false);
const hasGroupXAgain = await user.hasGroup("x");
expect(hasGroupXAgain).toBe(true);
expect.verifySteps(["res.users/has_group/x", "res.users/has_group/y"]);
});
test("set user settings do not override old valid keys", async () => {
await makeMockEnv();
patchWithCleanup(user, _makeUser({ user_settings: { a: 1, b: 2 } }));
onRpc("set_res_users_settings", (args) => {
expect.step(JSON.stringify(args.kwargs.new_settings));
return { a: 3, c: 4 };
});
expect(user.settings).toEqual({ a: 1, b: 2 });
await user.setUserSettings("a", 3);
expect.verifySteps(['{"a":3}']);
expect(user.settings).toEqual({ a: 3, b: 2, c: 4 });
});

View file

@ -0,0 +1,309 @@
import { describe, expect, test } from "@odoo/hoot";
import {
cartesian,
ensureArray,
groupBy,
intersection,
shallowEqual,
slidingWindow,
sortBy,
unique,
zip,
zipWith,
} from "@web/core/utils/arrays";
describe.current.tags("headless");
describe("groupby", () => {
test("groupBy parameter validations", () => {
// Safari: TypeError: undefined is not a function
// Other navigator: array is not iterable
expect(() => groupBy({})).toThrow(/TypeError: \w+ is not iterable/);
expect(() => groupBy([], true)).toThrow(
/Expected criterion of type 'string' or 'function' and got 'boolean'/
);
expect(() => groupBy([], 3)).toThrow(
/Expected criterion of type 'string' or 'function' and got 'number'/
);
expect(() => groupBy([], {})).toThrow(
/Expected criterion of type 'string' or 'function' and got 'object'/
);
});
test("groupBy (no criterion)", () => {
// criterion = default
expect(groupBy(["a", "b", 1, true])).toEqual({
1: [1],
a: ["a"],
b: ["b"],
true: [true],
});
});
test("groupBy by property", () => {
expect(groupBy([{ x: "a" }, { x: "a" }, { x: "b" }], "x")).toEqual({
a: [{ x: "a" }, { x: "a" }],
b: [{ x: "b" }],
});
});
test("groupBy", () => {
expect(groupBy(["a", "b", 1, true], (x) => `el${x}`)).toEqual({
ela: ["a"],
elb: ["b"],
el1: [1],
eltrue: [true],
});
});
});
describe("sortby", () => {
test("sortBy parameter validation", () => {
expect(() => sortBy({})).toThrow(/TypeError: \w+ is not iterable/);
expect(() => sortBy([Symbol("b"), Symbol("a")])).toThrow(
/(Cannot convert a (Symbol value)|(symbol) to a number)|(can't convert symbol to number)/
);
expect(() => sortBy([2, 1, 5], true)).toThrow(
/Expected criterion of type 'string' or 'function' and got 'boolean'/
);
expect(() => sortBy([2, 1, 5], 3)).toThrow(
/Expected criterion of type 'string' or 'function' and got 'number'/
);
expect(() => sortBy([2, 1, 5], {})).toThrow(
/Expected criterion of type 'string' or 'function' and got 'object'/
);
});
test("sortBy do not sort in place", () => {
const toSort = [2, 3, 1];
sortBy(toSort);
expect(toSort).toEqual([2, 3, 1]);
});
test("sortBy (no criterion)", () => {
expect(sortBy([])).toEqual([]);
expect(sortBy([2, 1, 5])).toEqual([1, 2, 5]);
expect(sortBy([true, false, true])).toEqual([false, true, true]);
expect(sortBy(["b", "a", "z"])).toEqual(["a", "b", "z"]);
expect(sortBy([{ x: true }, { x: false }, { x: true }])).toEqual([
{ x: true },
{ x: false },
{ x: true },
]);
expect(sortBy([{ x: 2 }, { x: 1 }, { x: 5 }])).toEqual([{ x: 2 }, { x: 1 }, { x: 5 }]);
expect(sortBy([{ x: "b" }, { x: "a" }, { x: "z" }])).toEqual([
{ x: "b" },
{ x: "a" },
{ x: "z" },
]);
});
test("sortBy property", () => {
expect(sortBy([], "x")).toEqual([]);
expect(sortBy([2, 1, 5], "x")).toEqual([2, 1, 5]);
expect(sortBy([true, false, true], "x")).toEqual([true, false, true]);
expect(sortBy(["b", "a", "z"], "x")).toEqual(["b", "a", "z"]);
expect(sortBy([{ x: true }, { x: false }, { x: true }], "x")).toEqual([
{ x: false },
{ x: true },
{ x: true },
]);
expect(sortBy([{ x: 2 }, { x: 1 }, { x: 5 }], "x")).toEqual([{ x: 1 }, { x: 2 }, { x: 5 }]);
expect(sortBy([{ x: "b" }, { x: "a" }, { x: "z" }], "x")).toEqual([
{ x: "a" },
{ x: "b" },
{ x: "z" },
]);
});
test("sortBy getter", () => {
const getter = (obj) => obj.x;
expect(sortBy([], getter)).toEqual([]);
expect(sortBy([2, 1, 5], getter)).toEqual([2, 1, 5]);
expect(sortBy([true, false, true], getter)).toEqual([true, false, true]);
expect(sortBy(["b", "a", "z"], getter)).toEqual(["b", "a", "z"]);
expect(sortBy([{ x: true }, { x: false }, { x: true }], getter)).toEqual([
{ x: false },
{ x: true },
{ x: true },
]);
expect(sortBy([{ x: 2 }, { x: 1 }, { x: 5 }], getter)).toEqual([
{ x: 1 },
{ x: 2 },
{ x: 5 },
]);
expect(sortBy([{ x: "b" }, { x: "a" }, { x: "z" }], getter)).toEqual([
{ x: "a" },
{ x: "b" },
{ x: "z" },
]);
});
test("sortBy descending order", () => {
expect(sortBy([2, 1, 5], null, "desc")).toEqual([5, 2, 1]);
expect(sortBy([{ x: "b" }, { x: "a" }, { x: "z" }], "x", "desc")).toEqual([
{ x: "z" },
{ x: "b" },
{ x: "a" },
]);
});
});
describe("intersection", () => {
test("intersection of arrays", () => {
expect(intersection([], [1, 2])).toEqual([]);
expect(intersection([1, 2], [])).toEqual([]);
expect(intersection([1], [2])).toEqual([]);
expect(intersection([1, 2], [2, 3])).toEqual([2]);
expect(intersection([1, 2, 3], [1, 2, 3])).toEqual([1, 2, 3]);
});
});
describe("cartesian", () => {
test("cartesian product of zero arrays", () => {
expect(cartesian()).toEqual([undefined], {
message: "the unit of the product is a singleton",
});
});
test("cartesian product of a single array", () => {
expect(cartesian([])).toEqual([]);
expect(cartesian([1])).toEqual([1], { message: "we don't want unecessary brackets" });
expect(cartesian([1, 2])).toEqual([1, 2]);
expect(cartesian([[1, 2]])).toEqual([[1, 2]], {
message: "the internal structure of elements should be preserved",
});
expect(
cartesian([
[1, 2],
[3, [2]],
])
).toEqual(
[
[1, 2],
[3, [2]],
],
{ message: "the internal structure of elements should be preserved" }
);
});
test("cartesian product of two arrays", () => {
expect(cartesian([], [])).toEqual([]);
expect(cartesian([1], [])).toEqual([]);
expect(cartesian([1], [2])).toEqual([[1, 2]]);
expect(cartesian([1, 2], [3])).toEqual([
[1, 3],
[2, 3],
]);
expect(cartesian([[1], 4], [2, [3]])).toEqual(
[
[[1], 2],
[[1], [3]],
[4, 2],
[4, [3]],
],
{ message: "the internal structure of elements should be preserved" }
);
});
test("cartesian product of three arrays", () => {
expect(cartesian([], [], [])).toEqual([]);
expect(cartesian([1], [], [2, 5])).toEqual([]);
expect(cartesian([1], [2], [3])).toEqual([[1, 2, 3]], {
message: "we should have no unecessary brackets, we want elements to be 'triples'",
});
expect(cartesian([[1], 2], [3], [4])).toEqual(
[
[[1], 3, 4],
[2, 3, 4],
],
{ message: "the internal structure of elements should be preserved" }
);
});
test("cartesian product of four arrays", () => {
expect(cartesian([1], [2], [3], [4])).toEqual([[1, 2, 3, 4]]);
});
});
describe("ensureArray", () => {
test("ensure array", () => {
const arrayRef = [];
expect(ensureArray(arrayRef)).not.toBe(arrayRef, {
message: "Should be a different array",
});
expect(ensureArray([])).toEqual([]);
expect(ensureArray()).toEqual([undefined]);
expect(ensureArray(null)).toEqual([null]);
expect(ensureArray({ a: 1 })).toEqual([{ a: 1 }]);
expect(ensureArray("foo")).toEqual(["foo"]);
expect(ensureArray([1, 2, "3"])).toEqual([1, 2, "3"]);
expect(ensureArray(new Set([1, 2, 3]))).toEqual([1, 2, 3]);
});
});
describe("unique", () => {
test("unique array", () => {
expect(unique([1, 2, 3, 2, 4, 3, 1, 4])).toEqual([1, 2, 3, 4]);
expect(unique("a d c a b c d b".split(" "))).toEqual("a d c b".split(" "));
});
});
describe("shallowEqual", () => {
test("simple valid cases", () => {
expect(shallowEqual([], [])).toBe(true);
expect(shallowEqual([1], [1])).toBe(true);
expect(shallowEqual([1, "a"], [1, "a"])).toBe(true);
});
test("simple invalid cases", () => {
expect(shallowEqual([1], [])).not.toBe(true);
expect(shallowEqual([], [1])).not.toBe(true);
expect(shallowEqual([1, "b"], [1, "a"])).not.toBe(true);
});
test("arrays with non primitive values", () => {
const obj = { b: 3 };
expect(shallowEqual([obj], [obj])).toBe(true);
expect(shallowEqual([{ b: 3 }], [{ b: 3 }])).not.toBe(true);
const arr = ["x", "y", "z"];
expect(shallowEqual([arr], [arr])).toBe(true);
expect(shallowEqual([["x", "y", "z"]], [["x", "y", "z"]])).not.toBe(true);
const fn = () => {};
expect(shallowEqual([fn], [fn])).toBe(true);
expect(shallowEqual([() => {}], [() => {}])).not.toBe(true);
});
});
describe("zip", () => {
test("zip", () => {
expect(zip([1, 2], [])).toEqual([]);
expect(zip([1, 2], ["a"])).toEqual([[1, "a"]]);
expect(zip([1, 2], ["a", "b"])).toEqual([
[1, "a"],
[2, "b"],
]);
});
});
describe("zipWith", () => {
test("zipWith", () => {
expect(zipWith([{ a: 1 }, { b: 2 }], ["a", "b"], (o, k) => o[k])).toEqual([1, 2]);
});
});
describe("slidingWindow", () => {
test("slidingWindow", () => {
expect(slidingWindow([1, 2, 3, 4], 2)).toEqual([
[1, 2],
[2, 3],
[3, 4],
]);
expect(slidingWindow([1, 2, 3, 4], 4)).toEqual([[1, 2, 3, 4]]);
expect(slidingWindow([1, 2, 3, 4], 5)).toEqual([]);
expect(slidingWindow([], 1)).toEqual([]);
});
});

View file

@ -0,0 +1,54 @@
import { describe, expect, test } from "@odoo/hoot";
import { manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
import { assets, loadCSS, loadJS } from "@web/core/assets";
describe.current.tags("headless");
/**
* @param {(node: Node) => void} callback
*/
const mockHeadAppendChild = (callback) =>
patchWithCleanup(document.head, {
appendChild: callback,
});
test("loadJS: load invalid JS lib", async () => {
expect.assertions(4);
mockHeadAppendChild((node) => {
expect(node).toBeInstanceOf(HTMLScriptElement);
expect(node).toHaveAttribute("type", "text/javascript");
expect(node).toHaveAttribute("src", "/some/invalid/file.js");
// Simulates a failed request to an invalid file.
manuallyDispatchProgrammaticEvent(node, "error");
});
await expect(loadJS("/some/invalid/file.js")).rejects.toThrow(
/The loading of \/some\/invalid\/file.js failed/,
{ message: "Trying to load an invalid file rejects the promise" }
);
});
test("loadCSS: load invalid CSS lib", async () => {
expect.assertions(4 * 4 + 1);
assets.retries = { count: 3, delay: 1, extraDelay: 1 }; // Fail fast.
mockHeadAppendChild((node) => {
expect(node).toBeInstanceOf(HTMLLinkElement);
expect(node).toHaveAttribute("rel", "stylesheet");
expect(node).toHaveAttribute("type", "text/css");
expect(node).toHaveAttribute("href", "/some/invalid/file.css");
// Simulates a failed request to an invalid file.
manuallyDispatchProgrammaticEvent(node, "error");
});
await expect(loadCSS("/some/invalid/file.css")).rejects.toThrow(
/The loading of \/some\/invalid\/file.css failed/,
{ message: "Trying to load an invalid file rejects the promise" }
);
});

View file

@ -0,0 +1,103 @@
import { expect, test } from "@odoo/hoot";
import { queryRect, queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, useRef, xml } from "@odoo/owl";
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { useAutoresize } from "@web/core/utils/autoresize";
test(`resizable input`, async () => {
class ResizableInput extends Component {
static template = xml`<input class="resizable-input" t-ref="input"/>`;
static props = ["*"];
setup() {
useAutoresize(useRef("input"));
}
}
await mountWithCleanup(ResizableInput);
const initialWidth = queryRect(`.resizable-input`).width;
await contains(`.resizable-input`).edit("new value");
expect(`.resizable-input`).not.toHaveRect({ width: initialWidth });
});
test(`resizable textarea`, async () => {
class ResizableTextArea extends Component {
static template = xml`<textarea class="resizable-textarea" t-ref="textarea"/>`;
static props = ["*"];
setup() {
useAutoresize(useRef("textarea"));
}
}
await mountWithCleanup(ResizableTextArea);
const initialHeight = queryRect(`.resizable-textarea`).height;
await contains(`.resizable-textarea`).edit("new value\n".repeat(5));
expect(`.resizable-textarea`).not.toHaveRect({ height: initialHeight });
});
test(`resizable textarea with minimum height`, async () => {
class ResizableTextArea extends Component {
static template = xml`<textarea class="resizable-textarea" t-ref="textarea"/>`;
static props = ["*"];
setup() {
useAutoresize(useRef("textarea"), { minimumHeight: 100 });
}
}
await mountWithCleanup(ResizableTextArea);
const initialHeight = queryRect(`.resizable-textarea`).height;
expect(initialHeight).toBe(100);
await contains(`.resizable-textarea`).edit("new value\n".repeat(5));
expect(`.resizable-textarea`).not.toHaveRect({ height: initialHeight });
});
test(`call onResize callback`, async () => {
class ResizableInput extends Component {
static template = xml`<input class="resizable-input" t-ref="input"/>`;
static props = ["*"];
setup() {
const inputRef = useRef("input");
useAutoresize(inputRef, {
randomParam: true,
onResize(el, options) {
expect.step("onResize");
expect(el).toBe(inputRef.el);
expect(options).toInclude("randomParam");
},
});
}
}
await mountWithCleanup(ResizableInput);
expect.verifySteps(["onResize"]);
await contains(`.resizable-input`).edit("new value", { instantly: true });
expect.verifySteps(["onResize"]);
});
test(`call onResize callback after resizing text area`, async () => {
class ResizableTextArea extends Component {
static template = xml`<textarea class="resizable-textarea" t-ref="textarea"/>`;
static props = ["*"];
setup() {
const textareaRef = useRef("textarea");
useAutoresize(textareaRef, {
onResize(el, options) {
expect.step("onResizeTextArea");
},
});
}
}
await mountWithCleanup(ResizableTextArea);
expect.verifySteps(["onResizeTextArea"]);
const target = queryOne(".resizable-textarea");
target.style.width = `500px`;
await animationFrame();
expect.verifySteps(["onResizeTextArea"]);
});

View file

@ -0,0 +1,14 @@
import { describe, expect, test } from "@odoo/hoot";
import { patchTranslations } from "@web/../tests/web_test_helpers";
import { humanSize } from "@web/core/utils/binary";
describe.current.tags("headless");
test("humanSize", () => {
patchTranslations();
expect(humanSize(0)).toBe("0.00 Bytes");
expect(humanSize(3)).toBe("3.00 Bytes");
expect(humanSize(2048)).toBe("2.00 Kb");
expect(humanSize(2645000)).toBe("2.52 Mb");
});

View file

@ -0,0 +1,36 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { Component, xml } from "@odoo/owl";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { ErrorHandler } from "@web/core/utils/components";
test("ErrorHandler component", async () => {
class Boom extends Component {
static template = xml`<div><t t-esc="this.will.throw"/></div>`;
static props = ["*"];
}
class Parent extends Component {
static template = xml`
<div>
<t t-if="flag">
<ErrorHandler onError="() => this.handleError()">
<Boom/>
</ErrorHandler>
</t>
<t t-else="">not boom</t>
</div>
`;
static components = { Boom, ErrorHandler };
static props = ["*"];
setup() {
this.flag = true;
}
handleError() {
this.flag = false;
this.render();
}
}
await mountWithCleanup(Parent, { noMainContainer: true });
expect(getFixture()).toHaveText("not boom");
});

View file

@ -0,0 +1,369 @@
import { describe, expect, test } from "@odoo/hoot";
import { tick } from "@odoo/hoot-mock";
import { Deferred, Mutex, KeepLast, Race } from "@web/core/utils/concurrency";
describe.current.tags("headless");
describe("Deferred", () => {
test("basic use", async () => {
const def1 = new Deferred();
def1.then((v) => expect.step(`ok (${v})`));
def1.resolve(44);
await tick();
expect.verifySteps(["ok (44)"]);
const def2 = new Deferred();
def2.catch((v) => expect.step(`ko (${v})`));
def2.reject(44);
await tick();
expect.verifySteps(["ko (44)"]);
});
});
describe("Mutex", () => {
test("simple scheduling", async () => {
const mutex = new Mutex();
const def1 = new Deferred();
const def2 = new Deferred();
mutex.exec(() => def1).then(() => expect.step("ok [1]"));
mutex.exec(() => def2).then(() => expect.step("ok [2]"));
expect.verifySteps([]);
def1.resolve();
await tick();
expect.verifySteps(["ok [1]"]);
def2.resolve();
await tick();
expect.verifySteps(["ok [2]"]);
});
test("simple scheduling (2)", async () => {
const mutex = new Mutex();
const def1 = new Deferred();
const def2 = new Deferred();
mutex.exec(() => def1).then(() => expect.step("ok [1]"));
mutex.exec(() => def2).then(() => expect.step("ok [2]"));
expect.verifySteps([]);
def2.resolve();
await tick();
expect.verifySteps([]);
def1.resolve();
await tick();
expect.verifySteps(["ok [1]", "ok [2]"]);
});
test("reject", async () => {
const mutex = new Mutex();
const def1 = new Deferred();
const def2 = new Deferred();
const def3 = new Deferred();
mutex.exec(() => def1).then(() => expect.step("ok [1]"));
mutex.exec(() => def2).catch(() => expect.step("ko [2]"));
mutex.exec(() => def3).then(() => expect.step("ok [3]"));
expect.verifySteps([]);
def1.resolve();
await tick();
expect.verifySteps(["ok [1]"]);
def2.reject({ name: "sdkjfmqsjdfmsjkdfkljsdq" });
await tick();
expect.verifySteps(["ko [2]"]);
def3.resolve();
await tick();
expect.verifySteps(["ok [3]"]);
});
test("getUnlockedDef checks", async () => {
const mutex = new Mutex();
const def1 = new Deferred();
const def2 = new Deferred();
mutex.getUnlockedDef().then(() => expect.step("mutex unlocked (1)"));
await tick();
expect.verifySteps(["mutex unlocked (1)"]);
mutex.exec(() => def1).then(() => expect.step("ok [1]"));
await tick();
mutex.getUnlockedDef().then(function () {
expect.step("mutex unlocked (2)");
});
expect.verifySteps([]);
mutex.exec(() => def2).then(() => expect.step("ok [2]"));
await tick();
expect.verifySteps([]);
def1.resolve();
await tick();
expect.verifySteps(["ok [1]"]);
def2.resolve();
await tick();
expect.verifySteps(["mutex unlocked (2)", "ok [2]"]);
});
test("error and getUnlockedDef", async () => {
const mutex = new Mutex();
const action = async () => {
await Promise.resolve();
throw new Error("boom");
};
mutex.exec(action).catch(() => expect.step("prom rejected"));
await tick();
expect.verifySteps(["prom rejected"]);
mutex.getUnlockedDef().then(() => expect.step("mutex unlocked"));
await tick();
expect.verifySteps(["mutex unlocked"]);
});
});
describe("KeepLast", () => {
test("basic use", async () => {
const keepLast = new KeepLast();
const def = new Deferred();
keepLast.add(def).then(() => expect.step("ok"));
expect.verifySteps([]);
def.resolve();
await tick();
expect.verifySteps(["ok"]);
});
test("rejected promise", async () => {
const keepLast = new KeepLast();
const def = new Deferred();
keepLast.add(def).catch(() => expect.step("ko"));
expect.verifySteps([]);
def.reject();
await tick();
expect.verifySteps(["ko"]);
});
test("two promises resolved in order", async () => {
const keepLast = new KeepLast();
const def1 = new Deferred();
const def2 = new Deferred();
keepLast.add(def1).then(() => {
throw new Error("should not be executed");
});
keepLast.add(def2).then(() => expect.step("ok [2]"));
expect.verifySteps([]);
def1.resolve();
await tick();
expect.verifySteps([]);
def2.resolve();
await tick();
expect.verifySteps(["ok [2]"]);
});
test("two promises resolved in reverse order", async () => {
const keepLast = new KeepLast();
const def1 = new Deferred();
const def2 = new Deferred();
keepLast.add(def1).then(() => {
throw new Error("should not be executed");
});
keepLast.add(def2).then(() => expect.step("ok [2]"));
expect.verifySteps([]);
def2.resolve();
await tick();
expect.verifySteps(["ok [2]"]);
def1.resolve();
await tick();
expect.verifySteps([]);
});
});
describe("Race", () => {
test("basic use", async () => {
const race = new Race();
const def = new Deferred();
race.add(def).then((v) => expect.step(`ok (${v})`));
expect.verifySteps([]);
def.resolve(44);
await tick();
expect.verifySteps(["ok (44)"]);
});
test("two promises resolved in order", async () => {
const race = new Race();
const def1 = new Deferred();
const def2 = new Deferred();
race.add(def1).then((v) => expect.step(`ok (${v}) [1]`));
race.add(def2).then((v) => expect.step(`ok (${v}) [2]`));
expect.verifySteps([]);
def1.resolve(44);
await tick();
expect.verifySteps(["ok (44) [1]", "ok (44) [2]"]);
def2.resolve();
await tick();
expect.verifySteps([]);
});
test("two promises resolved in reverse order", async () => {
const race = new Race();
const def1 = new Deferred();
const def2 = new Deferred();
race.add(def1).then((v) => expect.step(`ok (${v}) [1]`));
race.add(def2).then((v) => expect.step(`ok (${v}) [2]`));
expect.verifySteps([]);
def2.resolve(44);
await tick();
expect.verifySteps(["ok (44) [1]", "ok (44) [2]"]);
def1.resolve();
await tick();
expect.verifySteps([]);
});
test("multiple resolutions", async () => {
const race = new Race();
const def1 = new Deferred();
const def2 = new Deferred();
const def3 = new Deferred();
race.add(def1).then((v) => expect.step(`ok (${v}) [1]`));
def1.resolve(44);
await tick();
expect.verifySteps(["ok (44) [1]"]);
race.add(def2).then((v) => expect.step(`ok (${v}) [2]`));
race.add(def3).then((v) => expect.step(`ok (${v}) [3]`));
def2.resolve(44);
await tick();
expect.verifySteps(["ok (44) [2]", "ok (44) [3]"]);
});
test("catch rejected promise", async () => {
const race = new Race();
const def = new Deferred();
race.add(def).catch((v) => expect.step(`not ok (${v})`));
expect.verifySteps([]);
def.reject(44);
await tick();
expect.verifySteps(["not ok (44)"]);
});
test("first promise rejects first", async () => {
const race = new Race();
const def1 = new Deferred();
const def2 = new Deferred();
race.add(def1).catch((v) => expect.step(`not ok (${v}) [1]`));
race.add(def2).catch((v) => expect.step(`not ok (${v}) [2]`));
expect.verifySteps([]);
def1.reject(44);
await tick();
expect.verifySteps(["not ok (44) [1]", "not ok (44) [2]"]);
def2.resolve();
await tick();
expect.verifySteps([]);
});
test("second promise rejects after", async () => {
const race = new Race();
const def1 = new Deferred();
const def2 = new Deferred();
race.add(def1).then((v) => expect.step(`ok (${v}) [1]`));
race.add(def2).then((v) => expect.step(`ok (${v}) [2]`));
expect.verifySteps([]);
def1.resolve(44);
await tick();
expect.verifySteps(["ok (44) [1]", "ok (44) [2]"]);
def2.reject();
await tick();
expect.verifySteps([]);
});
test("second promise rejects first", async () => {
const race = new Race();
const def1 = new Deferred();
const def2 = new Deferred();
race.add(def1).catch((v) => expect.step(`not ok (${v}) [1]`));
race.add(def2).catch((v) => expect.step(`not ok (${v}) [2]`));
expect.verifySteps([]);
def2.reject(44);
await tick();
expect.verifySteps(["not ok (44) [1]", "not ok (44) [2]"]);
def1.resolve();
await tick();
expect.verifySteps([]);
});
test("first promise rejects after", async () => {
const race = new Race();
const def1 = new Deferred();
const def2 = new Deferred();
race.add(def1).then((v) => expect.step(`ok (${v}) [1]`));
race.add(def2).then((v) => expect.step(`ok (${v}) [2]`));
expect.verifySteps([]);
def2.resolve(44);
await tick();
expect.verifySteps(["ok (44) [1]", "ok (44) [2]"]);
def1.reject();
await tick();
expect.verifySteps([]);
});
test("getCurrentProm", async () => {
const race = new Race();
const def1 = new Deferred();
const def2 = new Deferred();
const def3 = new Deferred();
expect(race.getCurrentProm()).toBe(null);
race.add(def1);
race.getCurrentProm().then((v) => expect.step(`ok (${v})`));
def1.resolve(44);
await tick();
expect.verifySteps(["ok (44)"]);
expect(race.getCurrentProm()).toBe(null);
race.add(def2);
race.getCurrentProm().then((v) => expect.step(`ok (${v})`));
race.add(def3);
def3.resolve(44);
await tick();
expect.verifySteps(["ok (44)"]);
expect(race.getCurrentProm()).toBe(null);
});
});

View file

@ -0,0 +1,406 @@
import { expect, test } from "@odoo/hoot";
import { queryRect } from "@odoo/hoot-dom";
import { animationFrame, mockTouch } from "@odoo/hoot-mock";
import { Component, reactive, useRef, useState, xml } from "@odoo/owl";
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { useDraggable } from "@web/core/utils/draggable";
test("Parameters error handling", async () => {
expect.assertions(2);
const mountList = async (setupList) => {
class List extends Component {
static template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
static props = ["*"];
setup() {
setupList();
}
}
await mountWithCleanup(List);
};
// Incorrect params
await mountList(() => {
expect(() => useDraggable({})).toThrow(
`Error in hook useDraggable: missing required property "ref" in parameter`
);
});
await mountList(() => {
expect(() =>
useDraggable({
elements: ".item",
})
).toThrow(`Error in hook useDraggable: missing required property "ref" in parameter`);
});
// Correct params
await mountList(() => {
useDraggable({
ref: useRef("root"),
});
});
await mountList(() => {
useDraggable({
ref: {},
elements: ".item",
enable: false,
});
});
await mountList(() => {
useDraggable({
ref: useRef("root"),
elements: ".item",
});
});
});
test("Simple dragging in single group", async () => {
expect.assertions(11);
class List extends Component {
static template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
static props = ["*"];
setup() {
useDraggable({
ref: useRef("root"),
elements: ".item",
onDragStart({ element }) {
expect.step("start");
expect(element).toHaveText("1");
},
onDragEnd({ element }) {
expect.step("end");
expect(element).toHaveText("1");
expect(".item").toHaveCount(3);
expect(".item.o_dragged").toHaveCount(1);
},
onDrop({ element }) {
expect.step("drop");
expect(element).toHaveText("1");
},
});
}
}
await mountWithCleanup(List);
expect(".item").toHaveCount(3);
expect(".o_dragged").toHaveCount(0);
expect.verifySteps([]);
// First item after 2nd item
await contains(".item:first-child").dragAndDrop(".item:nth-child(2)");
expect(".item").toHaveCount(3);
expect(".o_dragged").toHaveCount(0);
expect.verifySteps(["start", "drop", "end"]);
});
test("Dynamically disable draggable feature", async () => {
expect.assertions(3);
const state = reactive({ enableDrag: true });
class List extends Component {
static template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
static props = ["*"];
setup() {
this.state = useState(state);
useDraggable({
ref: useRef("root"),
elements: ".item",
enable: () => this.state.enableDrag,
onDragStart() {
expect.step("start");
},
});
}
}
await mountWithCleanup(List);
expect.verifySteps([]);
// First item before last item
await contains(".item:first-child").dragAndDrop(".item:last-child");
// Drag should have occurred
expect.verifySteps(["start"]);
state.enableDrag = false;
await animationFrame();
// First item before last item
await contains(".item:first-child").dragAndDrop(".item:last-child");
// Drag shouldn't have occurred
expect.verifySteps([]);
});
test("Ignore specified elements", async () => {
expect.assertions(4);
class List extends Component {
static template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" class="item">
<span class="ignored" t-esc="i" />
<span class="not-ignored" t-esc="i" />
</li>
</ul>
</div>`;
static props = ["*"];
setup() {
useDraggable({
ref: useRef("root"),
elements: ".item",
ignore: ".ignored",
onDragStart() {
expect.step("start");
},
});
}
}
await mountWithCleanup(List);
expect.verifySteps([]);
// Drag root item element
await contains(".item:first-child").dragAndDrop(".item:nth-child(2)");
expect.verifySteps(["start"]);
// Drag ignored element
await contains(".item:first-child .not-ignored").dragAndDrop(".item:nth-child(2)");
expect.verifySteps(["start"]);
// Drag non-ignored element
await contains(".item:first-child .ignored").dragAndDrop(".item:nth-child(2)");
expect.verifySteps([]);
});
test("Ignore specific elements in a nested draggable", async () => {
expect.assertions(5);
class List extends Component {
static components = { List };
static template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[0, 1]" t-as="i" t-key="i"
t-attf-class="item parent #{ i % 2 ? 'ignored' : 'not-ignored' }">
<span t-esc="'parent' + i" />
<ul class="list">
<li t-foreach="[0, 1]" t-as="j" t-key="j"
t-attf-class="item child #{ j % 2 ? 'ignored' : 'not-ignored' }">
<span t-esc="'child' + j" />
</li>
</ul>
</li>
</ul>
</div>`;
static props = ["*"];
setup() {
useDraggable({
ref: useRef("root"),
elements: ".item",
preventDrag: (el) => el.classList.contains("ignored"),
onDragStart() {
expect.step("start");
},
});
}
}
await mountWithCleanup(List);
expect.verifySteps([]);
// Drag ignored under non-ignored -> block
await contains(".not-ignored.parent .ignored.child").dragAndDrop(
".not-ignored.parent .not-ignored.child"
);
expect.verifySteps([]);
// Drag not-ignored-under not-ignored -> succeed
await contains(".not-ignored.parent .not-ignored.child").dragAndDrop(
".not-ignored.parent .ignored.child"
);
expect.verifySteps(["start"]);
// Drag ignored under ignored -> block
await contains(".ignored.parent .ignored.child").dragAndDrop(
".ignored.parent .not-ignored.child"
);
expect.verifySteps([]);
// Drag not-ignored under ignored -> succeed
await contains(".ignored.parent .not-ignored.child").dragAndDrop(
".ignored.parent .ignored.child"
);
expect.verifySteps(["start"]);
});
test("Dragging element with touch event", async () => {
expect.assertions(4);
mockTouch(true);
class List extends Component {
static template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
static props = ["*"];
setup() {
useDraggable({
ref: useRef("root"),
elements: ".item",
onDragStart({ element }) {
expect.step("start");
expect(".item.o_dragged").toHaveCount(1);
},
onDragEnd() {
expect.step("end");
},
onDrop() {
expect.step("drop");
},
});
}
}
await mountWithCleanup(List);
expect.verifySteps([]);
// Should DnD, if the timing value is higher then the default delay value (300ms)
await contains(".item:first-child").dragAndDrop(".item:nth-child(2)");
expect(".item.o_touch_bounce").toHaveCount(0, {
message: "element no longer has the animation class applied",
});
expect.verifySteps(["start", "drop", "end"]);
});
test("Dragging element with touch event: initiation delay can be overrided", async () => {
mockTouch(true);
class List extends Component {
static template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
static props = ["*"];
setup() {
useDraggable({
ref: useRef("root"),
delay: 1000,
elements: ".item",
onDragStart() {
expect.step("start");
},
});
}
}
await mountWithCleanup(List);
await contains(".item:first-child").dragAndDrop(".item:nth-child(2)", {
pointerDownDuration: 700,
});
// Shouldn't DnD, if the timing value is below then the delay value (1000ms)
expect.verifySteps([]);
await contains(".item:first-child").dragAndDrop(".item:nth-child(2)", {
pointerDownDuration: 1200,
});
// Should DnD, if the timing value is higher then the delay value (1000ms)
expect.verifySteps(["start"]);
});
test.tags("desktop");
test("Elements are confined within their container", async () => {
class List extends Component {
static template = xml`
<div t-ref="root" class="root">
<ul class="list list-unstyled m-0 d-flex flex-column">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item w-50" />
</ul>
</div>
`;
static props = ["*"];
setup() {
useDraggable({
ref: useRef("root"),
elements: ".item",
});
}
}
await mountWithCleanup(List);
const containerRect = queryRect(".root");
const { moveTo, drop } = await contains(".item:first").drag({
initialPointerMoveDistance: 0,
position: { x: 0, y: 0 },
});
expect(".item:first").toHaveRect({
x: containerRect.x,
y: containerRect.y,
width: containerRect.width / 2,
});
await moveTo(".item:last-child", {
position: { x: 0, y: 9999 },
});
expect(".item:first").toHaveRect({
x: containerRect.x,
y: containerRect.y + containerRect.height - queryRect(".item:first").height,
});
await moveTo(".item:last-child", {
position: { x: 9999, y: 9999 },
});
expect(".item:first").toHaveRect({
x: containerRect.x + containerRect.width - queryRect(".item:first").width,
y: containerRect.y + containerRect.height - queryRect(".item:first").height,
});
await moveTo(".item:last-child", {
position: { x: -9999, y: -9999 },
});
expect(".item:first").toHaveRect({
x: containerRect.x,
y: containerRect.y,
});
await drop();
});

View file

@ -0,0 +1,57 @@
import { describe, expect, test } from "@odoo/hoot";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
import { memoize, uniqueId } from "@web/core/utils/functions";
describe.current.tags("headless");
test("memoize", () => {
let callCount = 0;
let lastReceivedArgs;
const func = function () {
lastReceivedArgs = [...arguments];
return callCount++;
};
const memoized = memoize(func);
const firstValue = memoized("first");
expect(callCount).toBe(1);
expect(lastReceivedArgs).toEqual(["first"]);
const secondValue = memoized("first");
// Subsequent calls to memoized function with the same argument do not call the original function again
expect(callCount).toBe(1);
// Subsequent call to memoized function with the same argument returns the same value
expect(firstValue).toBe(secondValue);
const thirdValue = memoized();
// Subsequent calls to memoized function with a different argument call the original function again
expect(callCount).toBe(2);
const fourthValue = memoized();
// Memoization also works with no first argument as a key
expect(thirdValue).toBe(fourthValue);
// Subsequent calls to memoized function with no first argument do not call the original function again
expect(callCount).toBe(2);
memoized(1, 2, 3);
expect(callCount).toBe(3);
// Arguments after the first one are passed through correctly
expect(lastReceivedArgs).toEqual([1, 2, 3]);
memoized(1, 20, 30);
// Subsequent calls to memoized function with more than one argument do not call the original function again even if the arguments other than the first have changed
expect(callCount).toBe(3);
});
test("memoized function inherit function name if possible", () => {
const memoized1 = memoize(function test() {});
expect(memoized1.name).toBe("test (memoized)");
const memoized2 = memoize(function () {});
expect(memoized2.name).toBe("memoized");
});
test("uniqueId", () => {
patchWithCleanup(uniqueId, { nextId: 0 });
expect(uniqueId("test_")).toBe("test_1");
expect(uniqueId("bla")).toBe("bla2");
expect(uniqueId("test_")).toBe("test_3");
expect(uniqueId("bla")).toBe("bla4");
expect(uniqueId("test_")).toBe("test_5");
expect(uniqueId("test_")).toBe("test_6");
expect(uniqueId("bla")).toBe("bla7");
});

View file

@ -0,0 +1,677 @@
import { describe, expect, getFixture, test } from "@odoo/hoot";
import { click, queryOne } from "@odoo/hoot-dom";
import { Deferred, animationFrame, mockTouch } from "@odoo/hoot-mock";
import {
contains,
getService,
makeMockEnv,
mountWithCleanup,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { Component, onMounted, reactive, useState, xml } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { CommandPalette } from "@web/core/commands/command_palette";
import { registry } from "@web/core/registry";
import {
useAutofocus,
useBus,
useChildRef,
useForwardRefToParent,
useService,
useServiceProtectMethodHandling,
useSpellCheck,
} from "@web/core/utils/hooks";
describe("useAutofocus", () => {
test.tags("desktop");
test("simple usecase", async () => {
const state = reactive({ text: "" });
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<span>
<input type="text" t-ref="autofocus" t-att-value="state.text" />
</span>
`;
setup() {
useAutofocus();
this.state = useState(state);
}
}
await mountWithCleanup(MyComponent);
expect("input").toBeFocused();
state.text = "a";
await animationFrame();
expect("input").toBeFocused();
});
test.tags("desktop");
test("simple usecase when input type is number", async () => {
const state = reactive({ counter: 0 });
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<span>
<input type="number" t-ref="autofocus" t-att-value="state.counter" />
</span>
`;
setup() {
useAutofocus();
this.state = useState(state);
}
}
await mountWithCleanup(MyComponent);
expect("input").toBeFocused();
state.counter++;
await animationFrame();
expect("input").toBeFocused();
});
test.tags("desktop");
test("conditional autofocus", async () => {
const state = reactive({ showInput: true });
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<span>
<input t-if="state.showInput" type="text" t-ref="autofocus" />
</span>
`;
setup() {
useAutofocus();
this.state = useState(state);
}
}
await mountWithCleanup(MyComponent);
expect("input").toBeFocused();
state.showInput = false;
await animationFrame();
expect(document.body).toBeFocused();
state.showInput = true;
await animationFrame();
expect("input").toBeFocused();
});
test("returns also a ref when screen has touch but it does not focus", async () => {
expect.assertions(2);
mockTouch(true);
class MyComponent extends Component {
static template = xml`
<span>
<input type="text" t-ref="autofocus" />
</span>
`;
static props = ["*"];
setup() {
const inputRef = useAutofocus();
onMounted(() => {
expect(inputRef.el).toBeInstanceOf(HTMLInputElement);
});
}
}
await mountWithCleanup(MyComponent);
expect("input").not.toBeFocused();
});
test("works when screen has touch and you provide mobile param", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<span>
<input type="text" t-ref="autofocus" />
</span>
`;
setup() {
useAutofocus({ mobile: true });
}
}
patchWithCleanup(browser, {
matchMedia: (media) => {
if (media === "(pointer:coarse)") {
return { matches: true };
}
this._super();
},
});
await mountWithCleanup(MyComponent);
expect("input").toBeFocused();
});
test.tags("desktop");
test("supports different ref names", async () => {
const state = reactive({ showSecond: true });
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<span>
<input type="text" t-ref="first" />
<input t-if="state.showSecond" type="text" t-ref="second" />
</span>
`;
setup() {
useAutofocus({ refName: "second" });
useAutofocus({ refName: "first" }); // test requires this at second position
this.state = useState(state);
}
}
await mountWithCleanup(MyComponent);
// "first" is focused first since it has the last call to "useAutofocus"
expect("input:first").toBeFocused();
// We now remove and add again the second input, which triggers the useEffect of the hook and and apply focus
state.showSecond = false;
await animationFrame();
expect("input:first").toBeFocused();
state.showSecond = true;
await animationFrame();
expect("input:last").toBeFocused();
});
test.tags("desktop");
test("can select its content", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<span>
<input type="text" value="input content" t-ref="autofocus" />
</span>
`;
setup() {
useAutofocus({ selectAll: true });
}
}
await mountWithCleanup(MyComponent);
expect("input").toBeFocused();
expect("input").toHaveProperty("selectionStart", 0);
expect("input").toHaveProperty("selectionEnd", 13);
});
test.tags("desktop");
test("autofocus outside of active element doesn't work (CommandPalette)", async () => {
const state = reactive({
showPalette: true,
text: "",
});
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<div>
<input type="text" t-ref="autofocus" t-att-value="state.text" />
</div>
`;
setup() {
useAutofocus();
this.state = useState(state);
}
}
await mountWithCleanup(MyComponent);
expect("input:first").toBeFocused();
getService("dialog").add(CommandPalette, {
config: { providers: [] },
});
await animationFrame();
expect(".o_command_palette").toHaveCount(1);
expect("input:first").not.toBeFocused();
state.text = "a";
await animationFrame();
expect("input:first").not.toBeFocused();
});
});
describe("useBus", () => {
test("simple usecase", async () => {
const state = reactive({ child: true });
class MyComponent extends Component {
static props = ["*"];
static template = xml`<div/>`;
setup() {
useBus(this.env.bus, "test-event", this.myCallback);
}
myCallback() {
expect.step("callback");
}
}
class Parent extends Component {
static components = { MyComponent };
static props = ["*"];
static template = xml`<MyComponent t-if="state.child" />`;
setup() {
this.state = useState(state);
}
}
const { bus } = await makeMockEnv();
await mountWithCleanup(Parent);
bus.trigger("test-event");
expect.verifySteps(["callback"]);
state.child = false;
await animationFrame();
bus.trigger("test-event");
expect.verifySteps([]);
});
});
describe("useService", () => {
test("unavailable service", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`<div/>`;
setup() {
useService("toy_service");
}
}
await expect(mountWithCleanup(MyComponent)).rejects.toThrow(
"Service toy_service is not available"
);
});
test("service that returns null", async () => {
let toyService;
class MyComponent extends Component {
static props = ["*"];
static template = xml`<div/>`;
setup() {
toyService = useService("toy_service");
}
}
registry.category("services").add("toy_service", {
name: "toy_service",
start: () => null,
});
await mountWithCleanup(MyComponent);
expect(toyService).toBe(null);
});
test("async service with protected methods", async () => {
useServiceProtectMethodHandling.fn = useServiceProtectMethodHandling.original;
const state = reactive({ child: true });
let nbCalls = 0;
let def = new Deferred();
let objectService;
let functionService;
class MyComponent extends Component {
static props = ["*"];
static template = xml`<div/>`;
setup() {
objectService = useService("object_service");
functionService = useService("function_service");
}
}
class Parent extends Component {
static components = { MyComponent };
static props = ["*"];
static template = xml`<MyComponent t-if="state.child" />`;
setup() {
this.state = useState(state);
}
}
registry.category("services").add("object_service", {
name: "object_service",
async: ["asyncMethod"],
start() {
return {
async asyncMethod() {
nbCalls++;
await def;
return this;
},
};
},
});
registry.category("services").add("function_service", {
name: "function_service",
async: true,
start() {
return async function asyncFunc() {
nbCalls++;
await def;
return this;
};
},
});
await mountWithCleanup(Parent);
// Functions and methods have the correct this
def.resolve();
await expect(objectService.asyncMethod()).resolves.toBe(objectService);
await expect(objectService.asyncMethod.call("boundThis")).resolves.toBe("boundThis");
await expect(functionService()).resolves.toBe(undefined);
await expect(functionService.call("boundThis")).resolves.toBe("boundThis");
expect(nbCalls).toBe(4);
// Functions that were called before the component is destroyed but resolved after never resolve
def = new Deferred();
objectService.asyncMethod().then(() => expect.step("resolved"));
objectService.asyncMethod.call("boundThis").then(() => expect.step("resolved"));
functionService().then(() => expect.step("resolved"));
functionService.call("boundThis").then(() => expect.step("resolved"));
expect(nbCalls).toBe(8);
state.child = false;
await animationFrame();
def.resolve();
expect.verifySteps([]);
// Calling the functions after the destruction rejects the promise
await expect(objectService.asyncMethod()).rejects.toThrow("Component is destroyed");
await expect(objectService.asyncMethod.call("boundThis")).rejects.toThrow(
"Component is destroyed"
);
await expect(functionService()).rejects.toThrow("Component is destroyed");
await expect(functionService.call("boundThis")).rejects.toThrow("Component is destroyed");
expect(nbCalls).toBe(8);
useServiceProtectMethodHandling.fn = useServiceProtectMethodHandling.mocked;
});
});
describe("useSpellCheck", () => {
test("ref is on the textarea", async () => {
// To understand correctly the test, refer to the MDN documentation of spellcheck.
class MyComponent extends Component {
static props = ["*"];
static template = xml`<div><textarea t-ref="spellcheck" class="textArea"/></div>`;
setup() {
useSpellCheck();
}
}
await mountWithCleanup(MyComponent);
expect(".textArea").toHaveProperty("spellcheck", true);
expect(".textArea").not.toHaveAttribute("spellcheck");
// Focus textarea
await click(".textArea");
expect(".textArea").toBeFocused();
// Click out to trigger blur
await click(getFixture());
expect(".textArea").toHaveProperty("spellcheck", false);
expect(".textArea").toHaveAttribute("spellcheck", "false");
// Focus textarea
await click(".textArea");
expect(".textArea").toHaveProperty("spellcheck", true);
expect(".textArea").toHaveAttribute("spellcheck", "true");
});
test("use a different refName", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`<div><textarea t-ref="myreference" class="textArea"/></div>`;
setup() {
useSpellCheck({ refName: "myreference" });
}
}
await mountWithCleanup(MyComponent);
expect(".textArea").toHaveProperty("spellcheck", true);
expect(".textArea").not.toHaveAttribute("spellcheck");
await click(".textArea");
expect(".textArea").toBeFocused();
// Click out to trigger blur
await click(getFixture());
// Once these assertions pass, it means that the hook is working.
expect(".textArea").toHaveProperty("spellcheck", false);
expect(".textArea").toHaveAttribute("spellcheck", "false");
});
test("ref is on the root element and two editable elements", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<div t-ref="spellcheck">
<textarea class="textArea"/>
<div contenteditable="true" class="editableDiv"/>
</div>`;
setup() {
useSpellCheck();
}
}
await mountWithCleanup(MyComponent);
expect(".textArea").toHaveProperty("spellcheck", true);
expect(".editableDiv").toHaveProperty("spellcheck", true);
expect(".textArea").not.toHaveAttribute("spellcheck");
expect(".editableDiv").not.toHaveAttribute("spellcheck");
// Focus textarea
await click(".textArea");
expect(".textArea").toBeFocused();
// Focus editable div
await click(".editableDiv");
expect(".editableDiv").toBeFocused();
// Click out to trigger blur
await click(getFixture());
expect(".textArea").toHaveProperty("spellcheck", false);
expect(".editableDiv").toHaveProperty("spellcheck", false);
expect(".textArea").toHaveAttribute("spellcheck", "false");
expect(".editableDiv").toHaveAttribute("spellcheck", "false");
// Focus textarea
await click(".textArea");
expect(".textArea").toHaveProperty("spellcheck", true);
expect(".textArea").toHaveAttribute("spellcheck", "true");
expect(".editableDiv").toHaveProperty("spellcheck", false);
expect(".editableDiv").toHaveAttribute("spellcheck", "false");
// Focus editable div
await click(".editableDiv");
expect(".textArea").toHaveProperty("spellcheck", false);
expect(".textArea").toHaveAttribute("spellcheck", "false");
expect(".editableDiv").toHaveProperty("spellcheck", true);
expect(".editableDiv").toHaveAttribute("spellcheck", "true");
});
test("ref is on the root element and one element has disabled the spellcheck", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<div t-ref="spellcheck">
<textarea class="textArea"/>
<div contenteditable="true" spellcheck="false" class="editableDiv"/>
</div>`;
setup() {
useSpellCheck();
}
}
await mountWithCleanup(MyComponent);
expect(".textArea").toHaveProperty("spellcheck", true);
expect(".editableDiv").toHaveProperty("spellcheck", false);
expect(".textArea").not.toHaveAttribute("spellcheck");
expect(".editableDiv").toHaveAttribute("spellcheck", "false");
// Focus textarea
await click(".textArea");
expect(".textArea").toBeFocused();
// Focus editable div
await click(".editableDiv");
expect(".editableDiv").toBeFocused();
// Click out to trigger blur
await click(getFixture());
expect(".textArea").toHaveProperty("spellcheck", false);
expect(".textArea").toHaveAttribute("spellcheck", "false");
expect(".editableDiv").toHaveProperty("spellcheck", false);
expect(".editableDiv").toHaveAttribute("spellcheck", "false");
// Focus textarea
await click(".textArea");
expect(".textArea").toHaveProperty("spellcheck", true);
expect(".textArea").toHaveAttribute("spellcheck", "true");
expect(".editableDiv").toHaveProperty("spellcheck", false);
expect(".editableDiv").toHaveAttribute("spellcheck", "false");
// Focus editable div
await click(".editableDiv");
expect(".textArea").toHaveProperty("spellcheck", false);
expect(".textArea").toHaveAttribute("spellcheck", "false");
expect(".editableDiv").toHaveProperty("spellcheck", false);
expect(".editableDiv").toHaveAttribute("spellcheck", "false");
});
test("ref is on an element with contenteditable attribute", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<div t-ref="spellcheck" contenteditable="true" class="editableDiv" />`;
setup() {
useSpellCheck();
}
}
await mountWithCleanup(MyComponent);
expect(".editableDiv").toHaveProperty("spellcheck", true);
await contains(".editableDiv").click();
expect(".editableDiv").toBeFocused();
expect(".editableDiv").toHaveAttribute("spellcheck", "true");
await click(getFixture());
expect(".editableDiv").toHaveAttribute("spellcheck", "false");
});
});
describe("useChildRef and useForwardRefToParent", () => {
test("simple usecase", async () => {
let childRef;
let parentRef;
class Child extends Component {
static props = ["*"];
static template = xml`<span t-ref="someRef" class="my_span">Hello</span>`;
setup() {
childRef = useForwardRefToParent("someRef");
}
}
class Parent extends Component {
static props = ["*"];
static template = xml`<div><Child someRef="someRef"/></div>`;
static components = { Child };
setup() {
this.someRef = useChildRef();
parentRef = this.someRef;
}
}
await mountWithCleanup(Parent);
expect(childRef.el).toBe(queryOne(".my_span"));
expect(parentRef.el).toBe(queryOne(".my_span"));
});
test("in a conditional child", async () => {
class Child extends Component {
static props = ["*"];
static template = xml`<span t-ref="someRef" class="my_span">Hello</span>`;
setup() {
useForwardRefToParent("someRef");
}
}
class Parent extends Component {
static props = ["*"];
static template = xml`<div><Child t-if="state.hasChild" someRef="someRef"/></div>`;
static components = { Child };
setup() {
this.someRef = useChildRef();
this.state = useState({ hasChild: true });
}
}
const parentComponent = await mountWithCleanup(Parent);
expect(".my_span").toHaveCount(1);
expect(parentComponent.someRef.el).toBe(queryOne(".my_span"));
parentComponent.state.hasChild = false;
await animationFrame();
expect(".my_span").toHaveCount(0);
expect(parentComponent.someRef.el).toBe(null);
parentComponent.state.hasChild = true;
await animationFrame();
expect(".my_span").toHaveCount(1);
expect(parentComponent.someRef.el).toBe(queryOne(".my_span"));
});
});

View file

@ -0,0 +1,40 @@
import { markup } from "@odoo/owl";
const Markup = markup().constructor;
import { describe, expect, test } from "@odoo/hoot";
import { htmlEscape, isHtmlEmpty, setElementContent } from "@web/core/utils/html";
describe.current.tags("headless");
test("htmlEscape escapes text", () => {
const res = htmlEscape("<p>test</p>");
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape keeps html markup", () => {
const res = htmlEscape(markup("<p>test</p>"));
expect(res.toString()).toBe("<p>test</p>");
expect(res).toBeInstanceOf(Markup);
});
test("isHtmlEmpty does not consider text as empty", () => {
expect(isHtmlEmpty("<p></p>")).toBe(false);
});
test("isHtmlEmpty considers empty html markup as empty", () => {
expect(isHtmlEmpty(markup("<p></p>"))).toBe(true);
});
test("setElementContent escapes text", () => {
const div = document.createElement("div");
setElementContent(div, "<p>test</p>");
expect(div.innerHTML).toBe("&lt;p&gt;test&lt;/p&gt;");
});
test("setElementContent keeps html markup", () => {
const div = document.createElement("div");
setElementContent(div, markup("<p>test</p>"));
expect(div.innerHTML).toBe("<p>test</p>");
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,337 @@
import { describe, expect, test } from "@odoo/hoot";
import { patchTranslations, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { localization } from "@web/core/l10n/localization";
import {
clamp,
floatIsZero,
formatFloat,
range,
roundDecimals,
roundPrecision,
} from "@web/core/utils/numbers";
describe.current.tags("headless");
test("clamp", () => {
expect(clamp(-5, 0, 10)).toBe(0);
expect(clamp(0, 0, 10)).toBe(0);
expect(clamp(2, 0, 10)).toBe(2);
expect(clamp(5, 0, 10)).toBe(5);
expect(clamp(7, 0, 10)).toBe(7);
expect(clamp(10, 0, 10)).toBe(10);
expect(clamp(15, 0, 10)).toBe(10);
});
test("range", () => {
expect(range(0, 10)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect(range(0, 35, 5)).toEqual([0, 5, 10, 15, 20, 25, 30]);
expect(range(-10, 6, 2)).toEqual([-10, -8, -6, -4, -2, 0, 2, 4]);
expect(range(0, -10, -1)).toEqual([0, -1, -2, -3, -4, -5, -6, -7, -8, -9]);
expect(range(4, -4, -1)).toEqual([4, 3, 2, 1, 0, -1, -2, -3]);
expect(range(1, 4, -1)).toEqual([]);
expect(range(1, -4, 1)).toEqual([]);
});
describe("roundPrecision", () => {
test("default method (HALF-UP)", () => {
expect(roundPrecision(1.0, 1)).toBe(1);
expect(roundPrecision(1.0, 0.1)).toBe(1);
expect(roundPrecision(1.0, 0.01)).toBe(1);
expect(roundPrecision(1.0, 0.001)).toBe(1);
expect(roundPrecision(1.0, 0.0001)).toBe(1);
expect(roundPrecision(1.0, 0.00001)).toBe(1);
expect(roundPrecision(1.0, 0.000001)).toBe(1);
expect(roundPrecision(1.0, 0.0000001)).toBe(1);
expect(roundPrecision(1.0, 0.00000001)).toBe(1);
expect(roundPrecision(0.5, 1)).toBe(1);
expect(roundPrecision(-0.5, 1)).toBe(-1);
expect(roundPrecision(2.6745, 0.001)).toBe(2.675);
expect(roundPrecision(-2.6745, 0.001)).toBe(-2.675);
expect(roundPrecision(2.6744, 0.001)).toBe(2.674);
expect(roundPrecision(-2.6744, 0.001)).toBe(-2.674);
expect(roundPrecision(0.0004, 0.001)).toBe(0);
expect(roundPrecision(-0.0004, 0.001)).toBe(0);
expect(roundPrecision(357.4555, 0.001)).toBe(357.456);
expect(roundPrecision(-357.4555, 0.001)).toBe(-357.456);
expect(roundPrecision(457.4554, 0.001)).toBe(457.455);
expect(roundPrecision(-457.4554, 0.001)).toBe(-457.455);
expect(roundPrecision(-457.4554, 0.05)).toBe(-457.45);
expect(roundPrecision(457.444, 0.5)).toBe(457.5);
expect(roundPrecision(457.3, 5)).toBe(455);
expect(roundPrecision(457.5, 5)).toBe(460);
expect(roundPrecision(457.1, 3)).toBe(456);
expect(roundPrecision(2.6735, 0.001)).toBe(2.674);
expect(roundPrecision(-2.6735, 0.001)).toBe(-2.674);
expect(roundPrecision(2.6745, 0.001)).toBe(2.675);
expect(roundPrecision(-2.6745, 0.001)).toBe(-2.675);
expect(roundPrecision(2.6744, 0.001)).toBe(2.674);
expect(roundPrecision(-2.6744, 0.001)).toBe(-2.674);
expect(roundPrecision(0.0004, 0.001)).toBe(0);
expect(roundPrecision(-0.0004, 0.001)).toBe(-0);
expect(roundPrecision(357.4555, 0.001)).toBe(357.456);
expect(roundPrecision(-357.4555, 0.001)).toBe(-357.456);
expect(roundPrecision(457.4554, 0.001)).toBe(457.455);
expect(roundPrecision(-457.4554, 0.001)).toBe(-457.455);
});
test("DOWN", () => {
// We use 2.425 because when normalizing 2.425 with precision=0.001 it gives
// us 2424.9999999999995 as value, and if not handle correctly the rounding DOWN
// value will be incorrect (should be 2.425 and not 2.424)
expect(roundPrecision(2.425, 0.001, "DOWN")).toBe(2.425);
expect(roundPrecision(2.4249, 0.001, "DOWN")).toBe(2.424);
expect(roundPrecision(-2.425, 0.001, "DOWN")).toBe(-2.425);
expect(roundPrecision(-2.4249, 0.001, "DOWN")).toBe(-2.424);
expect(roundPrecision(-2.5, 0.001, "DOWN")).toBe(-2.5);
expect(roundPrecision(1.8, 1, "DOWN")).toBe(1);
expect(roundPrecision(-1.8, 1, "DOWN")).toBe(-1);
});
test("HALF-DOWN", () => {
expect(roundPrecision(2.6735, 0.001, "HALF-DOWN")).toBe(2.673);
expect(roundPrecision(-2.6735, 0.001, "HALF-DOWN")).toBe(-2.673);
expect(roundPrecision(2.6745, 0.001, "HALF-DOWN")).toBe(2.674);
expect(roundPrecision(-2.6745, 0.001, "HALF-DOWN")).toBe(-2.674);
expect(roundPrecision(2.6744, 0.001, "HALF-DOWN")).toBe(2.674);
expect(roundPrecision(-2.6744, 0.001, "HALF-DOWN")).toBe(-2.674);
expect(roundPrecision(0.0004, 0.001, "HALF-DOWN")).toBe(0);
expect(roundPrecision(-0.0004, 0.001, "HALF-DOWN")).toBe(-0);
expect(roundPrecision(357.4555, 0.001, "HALF-DOWN")).toBe(357.455);
expect(roundPrecision(-357.4555, 0.001, "HALF-DOWN")).toBe(-357.455);
expect(roundPrecision(457.4554, 0.001, "HALF-DOWN")).toBe(457.455);
expect(roundPrecision(-457.4554, 0.001, "HALF-DOWN")).toBe(-457.455);
});
test("HALF-UP", () => {
expect(roundPrecision(2.6735, 0.001, "HALF-UP")).toBe(2.674);
expect(roundPrecision(-2.6735, 0.001, "HALF-UP")).toBe(-2.674);
expect(roundPrecision(2.6745, 0.001, "HALF-UP")).toBe(2.675);
expect(roundPrecision(-2.6745, 0.001, "HALF-UP")).toBe(-2.675);
expect(roundPrecision(2.6744, 0.001, "HALF-UP")).toBe(2.674);
expect(roundPrecision(-2.6744, 0.001, "HALF-UP")).toBe(-2.674);
expect(roundPrecision(0.0004, 0.001, "HALF-UP")).toBe(0);
expect(roundPrecision(-0.0004, 0.001, "HALF-UP")).toBe(-0);
expect(roundPrecision(357.4555, 0.001, "HALF-UP")).toBe(357.456);
expect(roundPrecision(-357.4555, 0.001, "HALF-UP")).toBe(-357.456);
expect(roundPrecision(457.4554, 0.001, "HALF-UP")).toBe(457.455);
expect(roundPrecision(-457.4554, 0.001, "HALF-UP")).toBe(-457.455);
});
test("HALF-EVEN", () => {
expect(roundPrecision(5.015, 0.01, "HALF-EVEN")).toBe(5.02);
expect(roundPrecision(-5.015, 0.01, "HALF-EVEN")).toBe(-5.02);
expect(roundPrecision(5.025, 0.01, "HALF-EVEN")).toBe(5.02);
expect(roundPrecision(-5.025, 0.01, "HALF-EVEN")).toBe(-5.02);
expect(roundPrecision(2.6735, 0.001, "HALF-EVEN")).toBe(2.674);
expect(roundPrecision(-2.6735, 0.001, "HALF-EVEN")).toBe(-2.674);
expect(roundPrecision(2.6745, 0.001, "HALF-EVEN")).toBe(2.674);
expect(roundPrecision(-2.6745, 0.001, "HALF-EVEN")).toBe(-2.674);
expect(roundPrecision(2.6744, 0.001, "HALF-EVEN")).toBe(2.674);
expect(roundPrecision(-2.6744, 0.001, "HALF-EVEN")).toBe(-2.674);
expect(roundPrecision(0.0004, 0.001, "HALF-EVEN")).toBe(0);
expect(roundPrecision(-0.0004, 0.001, "HALF-EVEN")).toBe(-0);
expect(roundPrecision(357.4555, 0.001, "HALF-EVEN")).toBe(357.456);
expect(roundPrecision(-357.4555, 0.001, "HALF-EVEN")).toBe(-357.456);
expect(roundPrecision(457.4554, 0.001, "HALF-EVEN")).toBe(457.455);
expect(roundPrecision(-457.4554, 0.001, "HALF-EVEN")).toBe(-457.455);
});
test("UP", () => {
// We use 8.175 because when normalizing 8.175 with precision=0.001 it gives
// us 8175,0000000001234 as value, and if not handle correctly the rounding UP
// value will be incorrect (should be 8,175 and not 8,176)
expect(roundPrecision(8.175, 0.001, "UP")).toBe(8.175);
expect(roundPrecision(8.1751, 0.001, "UP")).toBe(8.176);
expect(roundPrecision(-8.175, 0.001, "UP")).toBe(-8.175);
expect(roundPrecision(-8.1751, 0.001, "UP")).toBe(-8.176);
expect(roundPrecision(-6.0, 0.001, "UP")).toBe(-6);
expect(roundPrecision(1.8, 1, "UP")).toBe(2);
expect(roundPrecision(-1.8, 1, "UP")).toBe(-2);
});
});
test("roundDecimals", () => {
expect(roundDecimals(1.0, 0)).toBe(1);
expect(roundDecimals(1.0, 1)).toBe(1);
expect(roundDecimals(1.0, 2)).toBe(1);
expect(roundDecimals(1.0, 3)).toBe(1);
expect(roundDecimals(1.0, 4)).toBe(1);
expect(roundDecimals(1.0, 5)).toBe(1);
expect(roundDecimals(1.0, 6)).toBe(1);
expect(roundDecimals(1.0, 7)).toBe(1);
expect(roundDecimals(1.0, 8)).toBe(1);
expect(roundDecimals(0.5, 0)).toBe(1);
expect(roundDecimals(-0.5, 0)).toBe(-1);
expect(roundDecimals(2.6745, 3)).toBe(2.675);
expect(roundDecimals(-2.6745, 3)).toBe(-2.675);
expect(roundDecimals(2.6744, 3)).toBe(2.674);
expect(roundDecimals(-2.6744, 3)).toBe(-2.674);
expect(roundDecimals(0.0004, 3)).toBe(0);
expect(roundDecimals(-0.0004, 3)).toBe(0);
expect(roundDecimals(357.4555, 3)).toBe(357.456);
expect(roundDecimals(-357.4555, 3)).toBe(-357.456);
expect(roundDecimals(457.4554, 3)).toBe(457.455);
expect(roundDecimals(-457.4554, 3)).toBe(-457.455);
});
test("floatIsZero", () => {
expect(floatIsZero(1, 0)).toBe(false);
expect(floatIsZero(0.9999, 0)).toBe(false);
expect(floatIsZero(0.50001, 0)).toBe(false);
expect(floatIsZero(0.5, 0)).toBe(false);
expect(floatIsZero(0.49999, 0)).toBe(true);
expect(floatIsZero(0, 0)).toBe(true);
expect(floatIsZero(-0.49999, 0)).toBe(true);
expect(floatIsZero(-0.50001, 0)).toBe(false);
expect(floatIsZero(-0.5, 0)).toBe(false);
expect(floatIsZero(-0.9999, 0)).toBe(false);
expect(floatIsZero(-1, 0)).toBe(false);
expect(floatIsZero(0.1, 1)).toBe(false);
expect(floatIsZero(0.099999, 1)).toBe(false);
expect(floatIsZero(0.050001, 1)).toBe(false);
expect(floatIsZero(0.05, 1)).toBe(false);
expect(floatIsZero(0.049999, 1)).toBe(true);
expect(floatIsZero(0, 1)).toBe(true);
expect(floatIsZero(-0.049999, 1)).toBe(true);
expect(floatIsZero(-0.05, 1)).toBe(false);
expect(floatIsZero(-0.050001, 1)).toBe(false);
expect(floatIsZero(-0.099999, 1)).toBe(false);
expect(floatIsZero(-0.1, 1)).toBe(false);
expect(floatIsZero(0.01, 2)).toBe(false);
expect(floatIsZero(0.0099999, 2)).toBe(false);
expect(floatIsZero(0.005, 2)).toBe(false);
expect(floatIsZero(0.0050001, 2)).toBe(false);
expect(floatIsZero(0.0049999, 2)).toBe(true);
expect(floatIsZero(0, 2)).toBe(true);
expect(floatIsZero(-0.0049999, 2)).toBe(true);
expect(floatIsZero(-0.0050001, 2)).toBe(false);
expect(floatIsZero(-0.005, 2)).toBe(false);
expect(floatIsZero(-0.0099999, 2)).toBe(false);
expect(floatIsZero(-0.01, 2)).toBe(false);
// 4 and 5 decimal places are mentioned as special cases in `roundDecimals` method.
expect(floatIsZero(0.0001, 4)).toBe(false);
expect(floatIsZero(0.000099999, 4)).toBe(false);
expect(floatIsZero(0.00005, 4)).toBe(false);
expect(floatIsZero(0.000050001, 4)).toBe(false);
expect(floatIsZero(0.000049999, 4)).toBe(true);
expect(floatIsZero(0, 4)).toBe(true);
expect(floatIsZero(-0.000049999, 4)).toBe(true);
expect(floatIsZero(-0.000050001, 4)).toBe(false);
expect(floatIsZero(-0.00005, 4)).toBe(false);
expect(floatIsZero(-0.000099999, 4)).toBe(false);
expect(floatIsZero(-0.0001, 4)).toBe(false);
expect(floatIsZero(0.00001, 5)).toBe(false);
expect(floatIsZero(0.0000099999, 5)).toBe(false);
expect(floatIsZero(0.000005, 5)).toBe(false);
expect(floatIsZero(0.0000050001, 5)).toBe(false);
expect(floatIsZero(0.0000049999, 5)).toBe(true);
expect(floatIsZero(0, 5)).toBe(true);
expect(floatIsZero(-0.0000049999, 5)).toBe(true);
expect(floatIsZero(-0.0000050001, 5)).toBe(false);
expect(floatIsZero(-0.000005, 5)).toBe(false);
expect(floatIsZero(-0.0000099999, 5)).toBe(false);
expect(floatIsZero(-0.00001, 5)).toBe(false);
expect(floatIsZero(0.0000001, 7)).toBe(false);
expect(floatIsZero(0.000000099999, 7)).toBe(false);
expect(floatIsZero(0.00000005, 7)).toBe(false);
expect(floatIsZero(0.000000050001, 7)).toBe(false);
expect(floatIsZero(0.000000049999, 7)).toBe(true);
expect(floatIsZero(0, 7)).toBe(true);
expect(floatIsZero(-0.000000049999, 7)).toBe(true);
expect(floatIsZero(-0.000000050001, 7)).toBe(false);
expect(floatIsZero(-0.00000005, 7)).toBe(false);
expect(floatIsZero(-0.000000099999, 7)).toBe(false);
expect(floatIsZero(-0.0000001, 7)).toBe(false);
});
describe("formatFloat", () => {
test("localized", () => {
patchWithCleanup(localization, {
decimalPoint: ".",
grouping: [3, 0],
thousandsSep: ",",
});
expect(formatFloat(1000000)).toBe("1,000,000.00");
const options = { grouping: [3, 2, -1], decimalPoint: "?", thousandsSep: "€" };
expect(formatFloat(106500, options)).toBe("1€06€500?00");
expect(formatFloat(1500, { thousandsSep: "" })).toBe("1500.00");
expect(formatFloat(-1.01)).toBe("-1.01");
expect(formatFloat(-0.01)).toBe("-0.01");
expect(formatFloat(38.0001, { trailingZeros: false })).toBe("38");
expect(formatFloat(38.1, { trailingZeros: false })).toBe("38.1");
expect(formatFloat(38.0001, { digits: [16, 0], trailingZeros: false })).toBe("38");
patchWithCleanup(localization, { grouping: [3, 3, 3, 3] });
expect(formatFloat(1000000)).toBe("1,000,000.00");
patchWithCleanup(localization, { grouping: [3, 2, -1] });
expect(formatFloat(106500)).toBe("1,06,500.00");
patchWithCleanup(localization, { grouping: [1, 2, -1] });
expect(formatFloat(106500)).toBe("106,50,0.00");
patchWithCleanup(localization, {
decimalPoint: "!",
grouping: [2, 0],
thousandsSep: "@",
});
expect(formatFloat(6000)).toBe("60@00!00");
});
test("humanReadable", () => {
patchTranslations();
patchWithCleanup(localization, {
decimalPoint: ".",
grouping: [3, 0],
thousandsSep: ",",
});
const options = { humanReadable: true };
expect(formatFloat(1e18, options)).toBe("1E");
expect(formatFloat(-1e18, options)).toBe("-1E");
Object.assign(options, { decimals: 2, minDigits: 1 });
expect(formatFloat(1020, options)).toBe("1.02k");
expect(formatFloat(1002, options)).toBe("1.00k");
expect(formatFloat(101, options)).toBe("101.00");
expect(formatFloat(64.2, options)).toBe("64.20");
expect(formatFloat(1020, options)).toBe("1.02k");
expect(formatFloat(1e21, options)).toBe("1e+21");
expect(formatFloat(1.0045e22, options)).toBe("1e+22");
expect(formatFloat(1.012e43, options)).toBe("1.01e+43");
expect(formatFloat(-1020, options)).toBe("-1.02k");
expect(formatFloat(-1020, options)).toBe("-1.02k");
expect(formatFloat(-1002, options)).toBe("-1.00k");
expect(formatFloat(-101, options)).toBe("-101.00");
expect(formatFloat(-64.2, options)).toBe("-64.20");
expect(formatFloat(-1e21, options)).toBe("-1e+21");
expect(formatFloat(-1.0045e22, options)).toBe("-1e+22");
expect(formatFloat(-1.012e43, options)).toBe("-1.01e+43");
expect(formatFloat(-0.0000001, options)).toBe("0.00");
Object.assign(options, { decimals: 2, minDigits: 2 });
expect(formatFloat(1020000, options)).toBe("1,020k");
expect(formatFloat(10200000, options)).toBe("10.20M");
expect(formatFloat(1.012e43, options)).toBe("1.01e+43");
expect(formatFloat(-1020000, options)).toBe("-1,020k");
expect(formatFloat(-10200000, options)).toBe("-10.20M");
expect(formatFloat(-1.012e43, options)).toBe("-1.01e+43");
Object.assign(options, { decimals: 3, minDigits: 1 });
expect(formatFloat(1.0045e22, options)).toBe("1.005e+22");
expect(formatFloat(-1.0045e22, options)).toBe("-1.004e+22");
Object.assign(options, { humanReadable: false });
expect(formatFloat(-0.0000001, options)).toBe("0.00");
});
});

View file

@ -0,0 +1,207 @@
import { describe, expect, test } from "@odoo/hoot";
import {
deepCopy,
deepEqual,
isObject,
omit,
pick,
shallowEqual,
deepMerge,
} from "@web/core/utils/objects";
describe.current.tags("headless");
describe("shallowEqual", () => {
test("simple valid cases", () => {
expect(shallowEqual({}, {})).toBe(true);
expect(shallowEqual({ a: 1 }, { a: 1 })).toBe(true);
expect(shallowEqual({ a: 1, b: "x" }, { b: "x", a: 1 })).toBe(true);
});
test("simple invalid cases", () => {
expect(shallowEqual({ a: 1 }, { a: 2 })).toBe(false);
expect(shallowEqual({}, { a: 2 })).toBe(false);
expect(shallowEqual({ a: 1 }, {})).toBe(false);
});
test("objects with non primitive values", () => {
const obj = { x: "y" };
expect(shallowEqual({ a: obj }, { a: obj })).toBe(true);
expect(shallowEqual({ a: { x: "y" } }, { a: { x: "y" } })).toBe(false);
const arr = ["x", "y", "z"];
expect(shallowEqual({ a: arr }, { a: arr })).toBe(true);
expect(shallowEqual({ a: ["x", "y", "z"] }, { a: ["x", "y", "z"] })).toBe(false);
const fn = () => {};
expect(shallowEqual({ a: fn }, { a: fn })).toBe(true);
expect(shallowEqual({ a: () => {} }, { a: () => {} })).toBe(false);
});
test("custom comparison function", () => {
const dateA = new Date();
const dateB = new Date(dateA);
expect(shallowEqual({ a: 1, date: dateA }, { a: 1, date: dateB })).toBe(false);
expect(
shallowEqual({ a: 1, date: dateA }, { a: 1, date: dateB }, (a, b) =>
a instanceof Date ? Number(a) === Number(b) : a === b
)
).toBe(true);
});
});
test("deepEqual", () => {
const obj1 = {
a: ["a", "b", "c"],
o: {
b: true,
n: 10,
},
};
const obj2 = Object.assign({}, obj1);
const obj3 = Object.assign({}, obj2, { some: "thing" });
expect(deepEqual(obj1, obj2)).toBe(true);
expect(deepEqual(obj1, obj3)).toBe(false);
expect(deepEqual(obj2, obj3)).toBe(false);
});
test("deepCopy", () => {
const obj = {
a: ["a", "b", "c"],
o: {
b: true,
n: 10,
},
};
const copy = deepCopy(obj);
expect(copy).not.toBe(obj);
expect(copy).toEqual(obj);
expect(copy.a).not.toBe(obj.a);
expect(copy.o).not.toBe(obj.o);
expect(deepCopy(new Date())).not.toBeInstanceOf(Date);
expect(deepCopy(new Set(["a"]))).not.toBeInstanceOf(Set);
expect(deepCopy(new Map([["a", 1]]))).not.toBeInstanceOf(Map);
});
test("isObject", () => {
expect(isObject(null)).toBe(false);
expect(isObject(undefined)).toBe(false);
expect(isObject("a")).toBe(false);
expect(isObject(true)).toBe(false);
expect(isObject(false)).toBe(false);
expect(isObject(10)).toBe(false);
expect(isObject(10.01)).toBe(false);
expect(isObject([])).toBe(true);
expect(isObject([1, 2])).toBe(true);
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
expect(isObject(() => {})).toBe(true);
expect(isObject(new Set())).toBe(true);
expect(isObject(new Map())).toBe(true);
expect(isObject(new Date())).toBe(true);
});
test("omit", () => {
expect(omit({})).toEqual({});
expect(omit({}, "a")).toEqual({});
expect(omit({ a: 1 })).toEqual({ a: 1 });
expect(omit({ a: 1 }, "a")).toEqual({});
expect(omit({ a: 1, b: 2 }, "c", "a")).toEqual({ b: 2 });
expect(omit({ a: 1, b: 2 }, "b", "c")).toEqual({ a: 1 });
});
test("pick", () => {
expect(pick({})).toEqual({});
expect(pick({}, "a")).toEqual({});
expect(pick({ a: 3, b: "a", c: [] }, "a")).toEqual({ a: 3 });
expect(pick({ a: 3, b: "a", c: [] }, "a", "c")).toEqual({ a: 3, c: [] });
expect(pick({ a: 3, b: "a", c: [] }, "a", "b", "c")).toEqual({ a: 3, b: "a", c: [] });
// Non enumerable property
class MyClass {
get a() {
return 1;
}
}
const myClass = new MyClass();
Object.defineProperty(myClass, "b", { enumerable: false, value: 2 });
expect(pick(myClass, "a", "b")).toEqual({ a: 1, b: 2 });
});
test("deepMerge", () => {
expect(
deepMerge(
{
a: 1,
b: {
b_a: 1,
b_b: 2,
},
},
{
a: 2,
b: {
b_b: 3,
b_c: 4,
},
}
)
).toEqual({
a: 2,
b: {
b_a: 1,
b_b: 3,
b_c: 4,
},
});
expect(deepMerge({}, {})).toEqual({});
expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 });
expect(deepMerge({}, { a: 1 })).toEqual({ a: 1 });
expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 });
expect(deepMerge(undefined, { a: 1 })).toEqual({ a: 1 });
expect(deepMerge({ a: 1 }, undefined)).toEqual({ a: 1 });
expect(deepMerge(undefined, undefined)).toBe(undefined);
expect(deepMerge({ a: undefined, b: undefined }, { a: { foo: "bar" } })).toEqual({
a: { foo: "bar" },
b: undefined,
});
expect(deepMerge("foo", 1)).toBe(undefined);
expect(deepMerge(null, null)).toBe(undefined);
const f = () => {};
expect(deepMerge({ a: undefined }, { a: f })).toEqual({ a: f });
// There's no current use for arrays, support can be added if needed
expect(deepMerge({ a: [1, 2, 3] }, { a: [4] })).toEqual({ a: [4] });
const symbolA = Symbol("A");
const symbolB = Symbol("B");
expect(
deepMerge(
{
[symbolA]: 1,
},
{
[symbolA]: 3,
[symbolB]: 2,
}
)
).toEqual({
[symbolA]: 3,
[symbolB]: 2,
});
});

View file

@ -0,0 +1,871 @@
import { describe, expect, test } from "@odoo/hoot";
import { patch } from "@web/core/utils/patch";
class BaseClass {
static staticStr = "base";
static staticObj = { base: "base" };
static staticArr = ["base"];
static staticFn() {
expect.step("base.staticFn");
}
constructor() {
this.setup();
}
setup() {
this._dynamic = "base";
this.str = "base";
this.obj = { base: "base" };
this.arr = ["base"];
expect.step("base.setup");
}
fn() {
expect.step("base.fn");
}
async asyncFn() {
// also check this binding
expect.step(`base.${this.str}`);
}
get dynamic() {
return this._dynamic;
}
set dynamic(value) {
this._dynamic = value;
}
}
function applyGenericPatch(Klass, tag) {
return patch(Klass.prototype, {
setup() {
super.setup();
expect.step(`${tag}.setup`);
},
fn() {
super.fn();
expect.step(`${tag}.fn`);
},
async asyncFn() {
await Promise.resolve();
await super.asyncFn(...arguments);
// also check this binding
expect.step(`${tag}.${this.str}`);
},
});
}
function applyGenericStaticPatch(Klass, tag) {
return patch(Klass, {
staticStr: Klass.staticStr + tag,
staticArr: [...Klass.staticArr, tag],
staticObj: { ...Klass.staticObj, patch: tag },
staticFn() {
super.staticFn();
expect.step(`${tag}.staticFn`);
},
});
}
function createGenericExtension() {
return class Extension extends BaseClass {
static staticStr = BaseClass.staticStr + "extension";
static staticArr = [...BaseClass.staticArr, "extension"];
static staticObj = { ...BaseClass.staticObj, extension: "extension" };
static staticFn() {
super.staticFn();
expect.step("extension.staticFn");
}
setup() {
super.setup();
expect.step("extension.setup");
}
fn() {
super.fn();
expect.step("extension.fn");
}
};
}
describe.current.tags("headless");
test("one patch/unpatch", () => {
new BaseClass().fn();
expect.verifySteps(["base.setup", "base.fn"]);
const unpatch = applyGenericPatch(BaseClass, "patch");
new BaseClass().fn();
expect.verifySteps(["base.setup", "patch.setup", "base.fn", "patch.fn"]);
unpatch();
new BaseClass().fn();
expect.verifySteps(["base.setup", "base.fn"]);
});
test("two patch/unpatch (unpatch 1 > 2)", () => {
new BaseClass().fn();
expect.verifySteps(["base.setup", "base.fn"]);
const unpatch1 = applyGenericPatch(BaseClass, "patch1");
new BaseClass().fn();
expect.verifySteps(["base.setup", "patch1.setup", "base.fn", "patch1.fn"]);
const unpatch2 = applyGenericPatch(BaseClass, "patch2");
new BaseClass().fn();
expect.verifySteps([
"base.setup",
"patch1.setup",
"patch2.setup",
"base.fn",
"patch1.fn",
"patch2.fn",
]);
unpatch1();
new BaseClass().fn();
expect.verifySteps(["base.setup", "patch2.setup", "base.fn", "patch2.fn"]);
unpatch2();
new BaseClass().fn();
expect.verifySteps(["base.setup", "base.fn"]);
});
test("two patch/unpatch (unpatch 2 > 1)", () => {
new BaseClass().fn();
expect.verifySteps(["base.setup", "base.fn"]);
const unpatch1 = applyGenericPatch(BaseClass, "patch1");
new BaseClass().fn();
expect.verifySteps(["base.setup", "patch1.setup", "base.fn", "patch1.fn"]);
const unpatch2 = applyGenericPatch(BaseClass, "patch2");
new BaseClass().fn();
expect.verifySteps([
"base.setup",
"patch1.setup",
"patch2.setup",
"base.fn",
"patch1.fn",
"patch2.fn",
]);
unpatch2();
new BaseClass().fn();
expect.verifySteps(["base.setup", "patch1.setup", "base.fn", "patch1.fn"]);
unpatch1();
new BaseClass().fn();
expect.verifySteps(["base.setup", "base.fn"]);
});
test("patch for specialization", () => {
let args = [];
class A {
constructor() {
this.setup(...arguments);
}
setup() {
args = ["A", ...arguments];
}
}
const unpatch = patch(A.prototype, {
setup() {
super.setup("patch", ...arguments);
},
});
new A("instantiation");
expect(args).toEqual(["A", "patch", "instantiation"]);
unpatch();
});
test("instance fields", () => {
const unpatch = patch(BaseClass.prototype, {
setup() {
super.setup();
this.str += "patch";
this.arr.push("patch");
this.obj.patch = "patch";
},
});
const instance = new BaseClass();
expect.verifySteps(["base.setup"]);
expect(instance.str).toBe("basepatch");
expect(instance.arr).toEqual(["base", "patch"]);
expect(instance.obj).toEqual({ base: "base", patch: "patch" });
unpatch();
// unpatch does not change instance fields' values
expect(instance.str).toBe("basepatch");
expect(instance.arr).toEqual(["base", "patch"]);
expect(instance.obj).toEqual({ base: "base", patch: "patch" });
});
test("call instance method defined in patch", () => {
const instance = new BaseClass();
expect.verifySteps(["base.setup"]);
expect(instance).not.toInclude("f");
const unpatch = patch(BaseClass.prototype, {
f() {
expect.step("patch.f");
},
});
instance.f();
expect(instance).toInclude("f");
expect.verifySteps(["patch.f"]);
unpatch();
expect(instance).not.toInclude("f");
});
test("class methods", () => {
BaseClass.staticFn();
expect.verifySteps(["base.staticFn"]);
const unpatch = applyGenericStaticPatch(BaseClass, "patch");
BaseClass.staticFn();
expect.verifySteps(["base.staticFn", "patch.staticFn"]);
unpatch();
BaseClass.staticFn();
expect.verifySteps(["base.staticFn"]);
});
test("class fields", () => {
expect(BaseClass.staticStr).toBe("base");
expect(BaseClass.staticArr).toEqual(["base"]);
expect(BaseClass.staticObj).toEqual({ base: "base" });
const unpatch = applyGenericStaticPatch(BaseClass, "patch");
expect(BaseClass.staticStr).toBe("basepatch");
expect(BaseClass.staticArr).toEqual(["base", "patch"]);
expect(BaseClass.staticObj).toEqual({ base: "base", patch: "patch" });
unpatch();
expect(BaseClass.staticStr).toBe("base");
expect(BaseClass.staticArr).toEqual(["base"]);
expect(BaseClass.staticObj).toEqual({ base: "base" });
});
test("lazy patch", () => {
const instance = new BaseClass();
const unpatch = applyGenericPatch(BaseClass, "patch");
instance.fn();
expect.verifySteps(["base.setup", "base.fn", "patch.fn"]);
unpatch();
instance.fn();
expect.verifySteps(["base.fn"]);
});
test("getter", () => {
const instance = new BaseClass();
expect.verifySteps(["base.setup"]);
expect(instance.dynamic).toBe("base");
const unpatch = patch(BaseClass.prototype, {
get dynamic() {
return super.dynamic + "patch";
},
});
expect(instance.dynamic).toBe("basepatch");
unpatch();
expect(instance.dynamic).toBe("base");
});
test("setter", () => {
const instance = new BaseClass();
expect.verifySteps(["base.setup"]);
expect(instance.dynamic).toBe("base");
instance.dynamic = "1";
expect(instance.dynamic).toBe("1");
const unpatch = patch(BaseClass.prototype, {
set dynamic(value) {
super.dynamic = "patch:" + value;
},
});
expect(instance.dynamic).toBe("1"); // nothing changed
instance.dynamic = "2";
expect(instance.dynamic).toBe("patch:2");
unpatch();
instance.dynamic = "3";
expect(instance.dynamic).toBe("3");
});
test("patch getter/setter with value", () => {
const originalDescriptor = Object.getOwnPropertyDescriptor(BaseClass.prototype, "dynamic");
const unpatch = patch(BaseClass.prototype, { dynamic: "patched" });
const instance = new BaseClass();
expect.verifySteps(["base.setup"]);
expect(Object.getOwnPropertyDescriptor(BaseClass.prototype, "dynamic")).toEqual({
value: "patched",
writable: true,
configurable: true,
enumerable: false, // class properties are not enumerable
});
expect(instance.dynamic).toBe("patched");
unpatch();
instance.dynamic = "base";
expect(Object.getOwnPropertyDescriptor(BaseClass.prototype, "dynamic")).toEqual(
originalDescriptor
);
expect(instance.dynamic).toBe("base");
});
test("async function", async () => {
const instance = new BaseClass();
instance.str = "async1";
await instance.asyncFn();
expect.verifySteps(["base.setup", "base.async1"]);
const unpatch = applyGenericPatch(BaseClass, "patch");
instance.str = "async2";
await instance.asyncFn();
expect.verifySteps(["base.async2", "patch.async2"]);
unpatch();
instance.str = "async3";
await instance.asyncFn();
expect.verifySteps(["base.async3"]);
});
test("async function (multiple patches)", async () => {
const instance = new BaseClass();
instance.str = "async1";
await instance.asyncFn();
expect.verifySteps(["base.setup", "base.async1"]);
const unpatch1 = applyGenericPatch(BaseClass, "patch1");
const unpatch2 = applyGenericPatch(BaseClass, "patch2");
instance.str = "async2";
await instance.asyncFn();
expect.verifySteps(["base.async2", "patch1.async2", "patch2.async2"]);
unpatch1();
unpatch2();
instance.str = "async3";
await instance.asyncFn();
expect.verifySteps(["base.async3"]);
});
test("call another super method", () => {
new BaseClass();
expect.verifySteps(["base.setup"]);
const unpatch = patch(BaseClass.prototype, {
setup() {
expect.step("patch.setup");
super.fn();
},
fn() {
expect.step("patch.fn"); // should not called
},
});
new BaseClass();
expect.verifySteps(["patch.setup", "base.fn"]);
unpatch();
new BaseClass();
expect.verifySteps(["base.setup"]);
});
describe("inheritance", () => {
test("extend > patch base > unpatch base", () => {
const Extension = createGenericExtension();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
const unpatch = applyGenericPatch(BaseClass, "patch");
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
]);
unpatch();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
});
test("patch base > extend > unpatch base", () => {
const unpatch = applyGenericPatch(BaseClass, "patch");
const Extension = createGenericExtension();
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
]);
unpatch();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
});
test("extend > patch extension > unpatch extension", () => {
const Extension = createGenericExtension();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
const unpatch = applyGenericPatch(Extension, "patch.extension");
new Extension().fn();
expect.verifySteps([
"base.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"extension.fn",
"patch.extension.fn",
]);
unpatch();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
});
test("extend > patch base > patch extension > unpatch base > unpatch extension", () => {
const Extension = createGenericExtension();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
const unpatchBase = applyGenericPatch(BaseClass, "patch");
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
]);
const unpatchExtension = applyGenericPatch(Extension, "patch.extension");
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
"patch.extension.fn",
]);
unpatchBase();
new Extension().fn();
expect.verifySteps([
"base.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"extension.fn",
"patch.extension.fn",
]);
unpatchExtension();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
});
test("extend > patch base > patch extension > unpatch extension > unpatch base", () => {
const Extension = createGenericExtension();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
const unpatchBase = applyGenericPatch(BaseClass, "patch");
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
]);
const unpatchExtension = applyGenericPatch(Extension, "patch.extension");
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
"patch.extension.fn",
]);
unpatchExtension();
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
]);
unpatchBase();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
});
test("extend > patch extension > patch base > unpatch base > unpatch extension", () => {
const Extension = createGenericExtension();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
const unpatchExtension = applyGenericPatch(Extension, "patch.extension");
new Extension().fn();
expect.verifySteps([
"base.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"extension.fn",
"patch.extension.fn",
]);
const unpatchBase = applyGenericPatch(BaseClass, "patch");
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
"patch.extension.fn",
]);
unpatchBase();
new Extension().fn();
expect.verifySteps([
"base.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"extension.fn",
"patch.extension.fn",
]);
unpatchExtension();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
});
test("extend > patch extension > patch base > unpatch extension > unpatch base", () => {
const Extension = createGenericExtension();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
const unpatchExtension = applyGenericPatch(Extension, "patch.extension");
new Extension().fn();
expect.verifySteps([
"base.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"extension.fn",
"patch.extension.fn",
]);
const unpatchBase = applyGenericPatch(BaseClass, "patch");
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
"patch.extension.fn",
]);
unpatchExtension();
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
]);
unpatchBase();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
});
test("patch base > extend > patch extension > unpatch base > unpatch extension", () => {
const unpatchBase = applyGenericPatch(BaseClass, "patch");
const Extension = createGenericExtension();
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
]);
const unpatchExtension = applyGenericPatch(Extension, "patch.extension");
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
"patch.extension.fn",
]);
unpatchBase();
new Extension().fn();
expect.verifySteps([
"base.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"extension.fn",
"patch.extension.fn",
]);
unpatchExtension();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
});
test("patch base > extend > patch extension > unpatch extension > unpatch base", () => {
const unpatchBase = applyGenericPatch(BaseClass, "patch");
const Extension = createGenericExtension();
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
]);
const unpatchExtension = applyGenericPatch(Extension, "patch.extension");
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"patch.extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
"patch.extension.fn",
]);
unpatchExtension();
new Extension().fn();
expect.verifySteps([
"base.setup",
"patch.setup",
"extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
]);
unpatchBase();
new Extension().fn();
expect.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
});
test("class methods", () => {
const Extension = createGenericExtension();
Extension.staticFn();
expect.verifySteps(["base.staticFn", "extension.staticFn"]);
const unpatchBase = applyGenericStaticPatch(BaseClass, "patch");
Extension.staticFn();
expect.verifySteps(["base.staticFn", "patch.staticFn", "extension.staticFn"]);
const unpatchExtension = applyGenericStaticPatch(Extension, "patch.extension");
Extension.staticFn();
expect.verifySteps([
"base.staticFn",
"patch.staticFn",
"extension.staticFn",
"patch.extension.staticFn",
]);
unpatchBase();
Extension.staticFn();
expect.verifySteps(["base.staticFn", "extension.staticFn", "patch.extension.staticFn"]);
unpatchExtension();
Extension.staticFn();
expect.verifySteps(["base.staticFn", "extension.staticFn"]);
});
test("class fields (patch before inherit)", () => {
const unpatch = applyGenericStaticPatch(BaseClass, "patch");
const Extension = createGenericExtension();
expect(Extension.staticStr).toBe("basepatchextension");
expect(Extension.staticArr).toEqual(["base", "patch", "extension"]);
expect(Extension.staticObj).toEqual({
base: "base",
patch: "patch",
extension: "extension",
});
// /!\ WARNING /!\
// If inherit comes after the patch then extension will still have
// the patched data when unpatching.
unpatch();
expect(Extension.staticStr).toBe("basepatchextension");
expect(Extension.staticArr).toEqual(["base", "patch", "extension"]);
expect(Extension.staticObj).toEqual({
base: "base",
patch: "patch",
extension: "extension",
});
});
test("class fields (inherit before patch)", () => {
const Extension = createGenericExtension();
expect(Extension.staticStr).toBe("baseextension");
expect(Extension.staticArr).toEqual(["base", "extension"]);
expect(Extension.staticObj).toEqual({ base: "base", extension: "extension" });
// /!\ WARNING /!\
// If patch comes after the inherit then extension won't have
// the patched data.
const unpatch = applyGenericStaticPatch(BaseClass, "patch");
expect(Extension.staticStr).toBe("baseextension");
expect(Extension.staticArr).toEqual(["base", "extension"]);
expect(Extension.staticObj).toEqual({ base: "base", extension: "extension" });
unpatch();
expect(Extension.staticStr).toBe("baseextension");
expect(Extension.staticArr).toEqual(["base", "extension"]);
expect(Extension.staticObj).toEqual({ base: "base", extension: "extension" });
});
test("lazy patch", () => {
const Extension = createGenericExtension();
const instance = new Extension();
const unpatch = applyGenericPatch(BaseClass, "patch");
instance.fn();
expect.verifySteps([
"base.setup",
"extension.setup",
"base.fn",
"patch.fn",
"extension.fn",
]);
unpatch();
instance.fn();
expect.verifySteps(["base.fn", "extension.fn"]);
});
test("keep original descriptor details", () => {
class Klass {
// getter declared in classes are not enumerable
get getter() {
return false;
}
}
let descriptor = Object.getOwnPropertyDescriptor(Klass.prototype, "getter");
const getterFn = descriptor.get;
expect(descriptor.configurable).toBe(true);
expect(descriptor.enumerable).toBe(false);
patch(Klass.prototype, {
// getter declared in object are enumerable
get getter() {
return true;
},
});
descriptor = Object.getOwnPropertyDescriptor(Klass.prototype, "getter");
expect(descriptor.configurable).toBe(true);
expect(descriptor.enumerable).toBe(false);
expect(getterFn).not.toBe(descriptor.get);
});
});
describe("other", () => {
test("patch an object", () => {
const obj = {
var: "obj",
fn() {
expect.step("obj");
},
};
const unpatch = patch(obj, {
var: obj.var + "patch",
fn() {
super.fn();
expect.step("patch");
},
});
expect(obj.var).toBe("objpatch");
obj.fn();
expect.verifySteps(["obj", "patch"]);
unpatch();
expect(obj.var).toBe("obj");
obj.fn();
expect.verifySteps(["obj"]);
});
test("can call a non bound patched method", () => {
// use case: patching a function on window (e.g. setTimeout)
const obj = {
fn() {
expect.step("original");
},
};
const originalFn = obj.fn;
patch(obj, {
fn() {
expect.step("patched");
originalFn();
},
});
const fn = obj.fn; // purposely not bound
fn();
expect.verifySteps(["patched", "original"]);
});
});

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