19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -89,6 +89,49 @@ test("can be rendered", async () => {
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[0]);
});
// TODO: Hoot dispatches "change"/"blur" in the wrong order vs browser.
test.todo("select option with onChange", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`<AutoComplete value="state.value" sources="sources" onChange.bind="onChange" />`;
static props = [];
state = useState({ value: "" });
sources = buildSources(() => [
item("/contactus", this.onSelect.bind(this)),
item("/contactus-thank-you", this.onSelect.bind(this)),
]);
onChange({ inputValue, isOptionSelected }) {
expect.step(`isOptionSelected:${isOptionSelected}`);
if (isOptionSelected) {
return;
}
this.state.value = inputValue;
}
onSelect(option) {
this.state.value = option.label;
expect.step(option.label);
}
}
await mountWithCleanup(Parent);
await contains(".o-autocomplete input").edit("/", { confirm: false });
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
await runAllTimers();
expect.verifySteps(["isOptionSelected:true", "/contactus"]);
await contains(".o-autocomplete input").edit("hello", { confirm: "false" });
expect.verifySteps(["isOptionSelected:false"]);
await contains(document.body).click();
expect(".o-autocomplete input").toHaveValue("hello");
});
test("select option", async () => {
class Parent extends Component {
static components = { AutoComplete };
@ -573,7 +616,7 @@ test("correct sequence of blur, focus and select", async () => {
await contains(".o-autocomplete input").edit("", { confirm: false });
await runAllTimers();
await contains(document.body).click();
expect.verifySteps(["blur", "change"]);
expect.verifySteps(["change", "blur"]);
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
});
@ -647,6 +690,60 @@ test("tab and shift+tab close the dropdown", async () => {
expect(dropdown).not.toHaveCount();
});
test("Clicking away selects the first option when selectOnBlur is true", async () => {
class Parent extends Component {
static template = xml`<AutoComplete value="state.value" sources="sources" selectOnBlur="true"/>`;
static components = { AutoComplete };
static props = [];
state = useState({ value: "" });
sources = buildSources(() => [
item("World", this.onSelect.bind(this)),
item("Hello", this.onSelect.bind(this)),
]);
onSelect(option) {
this.state.value = option.label;
expect.step(option.label);
}
}
await mountWithCleanup(Parent);
const input = ".o-autocomplete input";
await contains(input).click();
expect(".o-autocomplete--dropdown-menu").toBeVisible();
queryFirst(input).blur();
await animationFrame();
expect(input).toHaveValue("World");
expect.verifySteps(["World"]);
});
test("selectOnBlur doesn't interfere with selecting by mouse clicking", async () => {
class Parent extends Component {
static template = xml`<AutoComplete value="state.value" sources="sources" selectOnBlur="true"/>`;
static components = { AutoComplete };
static props = [];
state = useState({ value: "" });
sources = buildSources(() => [
item("World", this.onSelect.bind(this)),
item("Hello", this.onSelect.bind(this)),
]);
onSelect(option) {
this.state.value = option.label;
expect.step(option.label);
}
}
await mountWithCleanup(Parent);
const input = ".o-autocomplete input";
await contains(input).click();
await contains(".o-autocomplete--dropdown-item:last").click();
expect(input).toHaveValue("Hello");
expect.verifySteps(["Hello"]);
});
test("autocomplete scrolls when moving with arrows", async () => {
class Parent extends Component {
static template = xml`
@ -836,3 +933,32 @@ test("items are selected only when the mouse moves, not just on enter", async ()
"ui-state-active"
);
});
test("do not attempt to scroll if element is null", async () => {
const def = new Deferred();
class Parent extends Component {
static template = xml`<AutoComplete value="''" sources="sources" />`;
static components = { AutoComplete };
static props = [];
sources = [
buildSources(async () => {
await def;
return [item("delayed one"), item("delayed two"), item("delayed three")];
}),
buildSources(Array.from(Array(20)).map((_, index) => item(`item ${index}`))),
].flat();
}
await mountWithCleanup(Parent);
queryOne(`.o-autocomplete input`).focus();
queryOne(`.o-autocomplete input`).click();
await animationFrame();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
expect(".o-autocomplete .dropdown-item").toHaveCount(21);
expect(".o-autocomplete .dropdown-item:eq(0)").toHaveClass("o_loading");
def.resolve();
await animationFrame();
expect(".o-autocomplete .dropdown-item").toHaveCount(23); // + 3 items - loading
});

View file

@ -1,6 +1,6 @@
import { test, expect } from "@odoo/hoot";
import { press, click, animationFrame, queryOne } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { press, click, animationFrame, queryOne, manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom";
import { Component, xml, useState } from "@odoo/owl";
import { defineStyle, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { ColorPicker, DEFAULT_COLORS } from "@web/core/color_picker/color_picker";
import { CustomColorPicker } from "@web/core/color_picker/custom_color_picker/custom_color_picker";
@ -230,6 +230,42 @@ test("custom color picker sets default color as selected", async () => {
expect("input.o_hex_input").toHaveValue("#FF0000");
});
test("should preserve color slider when picking max lightness color", async () => {
class TestColorPicker extends Component {
static template = xml`
<div style="width: 222px">
<CustomColorPicker selectedColor="state.color" onColorPreview.bind="onColorChange" onColorSelect.bind="onColorChange"/>
</div>`;
static components = { CustomColorPicker };
static props = ["*"];
setup() {
this.state = useState({
color: "#FFFF00",
});
}
onColorChange({ cssColor }) {
this.state.color = cssColor;
}
}
await mountWithCleanup(TestColorPicker);
const colorPickerArea = queryOne(".o_color_pick_area");
const colorPickerRect = colorPickerArea.getBoundingClientRect();
const clientX = colorPickerRect.left + colorPickerRect.width / 2;
const clientY = colorPickerRect.top; // Lightness 100%
manuallyDispatchProgrammaticEvent(colorPickerArea, "pointerdown", {
clientX,
clientY,
});
manuallyDispatchProgrammaticEvent(colorPickerArea, "pointerup", {
clientX,
clientY,
});
await animationFrame();
expect(colorPickerArea).toHaveStyle({ backgroundColor: "rgb(255, 255, 0)" });
});
test("custom color picker change color on click in hue slider", async () => {
await mountWithCleanup(CustomColorPicker, { props: { selectedColor: "#FF0000" } });
expect("input.o_hex_input").toHaveValue("#FF0000");
@ -269,3 +305,25 @@ test("can register an extra tab", async () => {
expect(".o_font_color_selector>p:last-child").toHaveText("Color picker extra tab");
registry.category("color_picker_tabs").remove("web.extra");
});
test("should mark default color as selected when it is selected", async () => {
defineStyle(`
:root {
--900: #212527;
}
`);
await mountWithCleanup(ColorPicker, {
props: {
state: {
selectedColor: "#212527",
defaultTab: "custom",
},
getUsedCustomColors: () => [],
applyColor() {},
applyColorPreview() {},
applyColorResetPreview() {},
colorPrefix: "",
},
});
expect(".o_color_button[data-color='900']").toHaveClass("selected");
});

View file

@ -1291,16 +1291,21 @@ test("bold the searchValue on the commands with special char", async () => {
const action = () => {};
const providers = [
{
namespace: "/",
provide: () => [
{
name: "Test&",
action,
},
{
name: "Research & Development",
action,
},
],
},
];
const config = {
searchValue: "&",
searchValue: "/",
providers,
};
getService("dialog").add(CommandPalette, {
@ -1308,9 +1313,29 @@ test("bold the searchValue on the commands with special char", async () => {
});
await animationFrame();
expect(".o_command_palette").toHaveCount(1);
expect(".o_command").toHaveCount(1);
expect(queryAllTexts(".o_command")).toEqual(["Test&"]);
expect(queryAllTexts(".o_command .fw-bolder")).toEqual(["&"]);
expect(".o_command").toHaveCount(2);
expect(queryAllTexts(".o_command")).toEqual(["Test&", "Research & Development"]);
expect(queryAllTexts(".o_command .fw-bolder")).toEqual([]);
await click(".o_command_palette_search input");
await edit("/a");
await runAllTimers();
expect(".o_command").toHaveCount(2);
expect(
queryAll(".o_command").map((command) =>
queryAllTexts(".o_command_name .fw-bolder", { root: command })
)
).toEqual([[], ["a"]]);
await click(".o_command_palette_search input");
await edit("/&");
await runAllTimers();
expect(".o_command").toHaveCount(2);
expect(
queryAll(".o_command").map((command) =>
queryAllTexts(".o_command_name .fw-bolder", { root: command })
)
).toEqual([["&"], ["&"]]);
});
test("bold the searchValue on the commands with accents", async () => {

View file

@ -5,6 +5,7 @@ import { Component, reactive, useState, xml } from "@odoo/owl";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
import { useDateTimePicker } from "@web/core/datetime/datetime_picker_hook";
import { usePopover } from "@web/core/popover/popover_hook";
const { DateTime } = luxon;
@ -143,3 +144,55 @@ test("value is not updated if it did not change", async () => {
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({
createPopover: usePopover,
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,56 @@
import { CopyButton } from "@web/core/copy_button/copy_button";
import { browser } from "@web/core/browser/browser";
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { beforeEach, expect, test } from "@odoo/hoot";
import { click } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
beforeEach(() => {
patchWithCleanup(browser.navigator.clipboard, {
async writeText(text) {
expect.step(`writeText: ${text}`);
},
async write(object) {
expect.step(
`write: {${Object.entries(object)
.map(([k, v]) => k + ": " + v)
.join(", ")}}`
);
},
});
});
test("copies a string to the clipboard", async () => {
await mountWithCleanup(CopyButton, { props: { content: "content to copy" } });
await click(".o_clipboard_button");
expect.verifySteps(["writeText: content to copy"]);
});
test("copies an object to the clipboard", async () => {
await mountWithCleanup(CopyButton, { props: { content: { oneKey: "oneValue" } } });
await click(".o_clipboard_button");
expect.verifySteps(["write: {oneKey: oneValue}"]);
});
test("does not submit forms", async () => {
class Parent extends Component {
static props = ["*"];
static components = { CopyButton };
static template = xml`
<form t-on-submit="this.onSubmit">
<CopyButton content="'some text'"/>
<!-- note that type="submit" is implicit on the following button -->
<button class="submit-button"/>
</form>
`;
onSubmit(ev) {
ev.preventDefault();
expect.step("form submit");
}
}
await mountWithCleanup(Parent);
await click(".o_clipboard_button");
expect.verifySteps(["writeText: some text"]);
await click(".submit-button");
expect.verifySteps(["form submit"]);
});

View file

@ -13,7 +13,7 @@ import { Dialog } from "@web/core/dialog/dialog";
import { useService } from "@web/core/utils/hooks";
test("simple rendering", async () => {
expect.assertions(8);
expect.assertions(7);
class Parent extends Component {
static components = { Dialog };
static template = xml`
@ -33,10 +33,7 @@ test("simple rendering", async () => {
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");
expect(".o_dialog footer:visible").toHaveCount(0, { message: "the footer is hidden if empty" });
});
test("hotkeys work on dialogs", async () => {
@ -45,9 +42,15 @@ test("hotkeys work on dialogs", async () => {
static template = xml`
<Dialog title="'Wow(l) Effect'">
Hello!
<t t-set-slot="footer">
<button t-on-click="onClickOk">Ok</button>
</t>
</Dialog>
`;
static props = ["*"];
onClickOk() {
expect.step("clickOk");
}
}
await makeDialogMockEnv({
dialogData: {
@ -65,7 +68,7 @@ test("hotkeys work on dialogs", async () => {
// Same effect as clicking on the Ok button
await keyDown("control+enter");
await keyUp("ctrl+enter");
expect.verifySteps(["close"]);
expect.verifySteps(["clickOk"]);
});
test("simple rendering with two dialogs", async () => {
@ -143,30 +146,6 @@ test("click on the button x triggers the close and dismiss defined by a Child co
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 {

View file

@ -1,4 +1,4 @@
import { test, expect, beforeEach, describe } from "@odoo/hoot";
import { test, expect, beforeEach } 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";
@ -8,8 +8,6 @@ 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);
});
@ -25,7 +23,7 @@ test("Simple rendering with a single dialog", async () => {
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Welcome");
await click(".o_dialog footer button");
await click(".o_dialog button");
await animationFrame();
expect(".o_dialog").toHaveCount(0);
});
@ -68,7 +66,7 @@ test("rendering with two dialogs", async () => {
await animationFrame();
expect(".o_dialog").toHaveCount(2);
expect(queryAllTexts("header .modal-title")).toEqual(["Hello", "Sauron"]);
await click(".o_dialog footer button");
await click(".o_dialog button");
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Sauron");
@ -174,7 +172,7 @@ test("Interactions between multiple dialogs", async () => {
expect(res.active).toEqual([false, true]);
expect(res.names).toEqual(["Hello", "Sauron"]);
await click(".o_dialog:not(.o_inactive_modal) footer button");
await click(".o_dialog:not(.o_inactive_modal) button");
await animationFrame();
expect(".o_dialog").toHaveCount(1);
@ -182,7 +180,7 @@ test("Interactions between multiple dialogs", async () => {
expect(res.active).toEqual([true]);
expect(res.names).toEqual(["Hello"]);
await click("footer button");
await click(".o_dialog:not(.o_inactive_modal) button");
await animationFrame();
expect(".o_dialog").toHaveCount(0);
});

View file

@ -787,7 +787,11 @@ test("don't close dropdown outside the active element", async () => {
expect(".modal-dialog").toHaveCount(1);
expect(DROPDOWN_MENU).toHaveCount(1);
await click(".modal-dialog .btn-primary");
if (getMockEnv().isSmall) {
await click(".modal-dialog .oi-arrow-left");
} else {
await click(".modal-dialog .btn-close");
}
await animationFrame();
expect(".modal-dialog").toHaveCount(0);
expect(DROPDOWN_MENU).toHaveCount(1);

View file

@ -193,3 +193,23 @@ test("Upload button is disabled if attachment upload is not finished", async ()
message: "the upload button should be enabled for upload",
});
});
test("support preprocessing of files via props", async () => {
await createFileInput({
props: {
onWillUploadFiles(files) {
expect.step(files[0].name);
return files;
},
},
mockPost: (route, params) => {
return JSON.stringify([{ name: params.ufile[0].name }]);
},
});
await contains(".o_file_input input", { visible: false }).click();
await setInputFiles([new File(["test"], "fake_file.txt", { type: "text/plain" })]);
await animationFrame();
expect.verifySteps(["fake_file.txt"]);
});

View file

@ -1,4 +1,4 @@
import { expect, test } from "@odoo/hoot";
import { expect, test, waitFor } from "@odoo/hoot";
import {
contains,
getService,
@ -61,13 +61,12 @@ test("upload renders new component(s)", async () => {
test("upload end removes component", async () => {
await mountWithCleanup(Parent);
onRpc("/test/", () => true);
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);
expect(".o_notification").toHaveCount(0);
});
test("upload error removes component", async () => {
@ -125,3 +124,49 @@ test("upload updates on progress", async () => {
await animationFrame();
expect(".file_upload_progress_text_right").toHaveText("(350/500MB)");
});
test("handles error", async () => {
await mountWithCleanup(Parent);
onRpc("/test/", () => {
throw new Error("Boom");
});
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await waitFor(".o_notification:has(.bg-danger):contains(An error occured while uploading)");
});
test("handles http not success", async () => {
await mountWithCleanup(Parent);
onRpc("/test/", () => new Response("<p>Boom HTML</p>", { status: 500 }));
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await waitFor(".o_notification:has(.bg-danger):contains(Boom HTML)");
});
test("handles jsonrpc error", async () => {
// https://www.jsonrpc.org/specification#error_object
await mountWithCleanup(Parent);
onRpc("/test/", () => ({
error: {
message: "Boom JSON",
},
}));
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await waitFor(".o_notification:has(.bg-danger):contains(Boom JSON)");
});
test("handles Odoo's jsonrpc error", async () => {
await mountWithCleanup(Parent);
onRpc("/test/", () => ({
error: {
data: {
name: "ValidationError",
message: "Boom Odoo",
},
},
}));
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await waitFor(".o_notification:has(.bg-danger):contains(ValidationError: Boom Odoo)");
});

View file

@ -1,10 +1,11 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { animationFrame, mockFetch } from "@odoo/hoot-mock";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import {
contains,
makeMockEnv,
mountWithCleanup,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
@ -34,8 +35,8 @@ test("Installation page displays the app info correctly", async () => {
},
});
mountManifestLink("/web/manifest.scoped_app_manifest");
mockFetch((route) => {
expect.step(route);
onRpc("/*", (request) => {
expect.step(new URL(request.url).pathname);
return {
icons: [
{
@ -73,7 +74,7 @@ test("Installation page displays the app info correctly", async () => {
expect("button.btn-primary").toHaveCount(1);
expect("button.btn-primary").toHaveText("Install");
await contains(".fa-pencil").click();
await contains("input").edit("<Otto&");
await contains("input").edit("<Otto&", { confirm: "blur" });
expect.verifySteps(["URL replace"]);
});
@ -81,8 +82,8 @@ test("Installation page displays the error message when browser is not supported
delete browser.BeforeInstallPromptEvent;
await makeMockEnv();
mountManifestLink("/web/manifest.scoped_app_manifest");
mockFetch((route) => {
expect.step(route);
onRpc("/*", (request) => {
expect.step(new URL(request.url).pathname);
return {
icons: [
{

View file

@ -1,5 +1,6 @@
/* eslint no-restricted-syntax: 0 */
import { after, describe, expect, test } from "@odoo/hoot";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import {
defineParams,
makeMockEnv,
@ -10,9 +11,8 @@ import {
serverState,
} from "@web/../tests/web_test_helpers";
import { _t as basic_t, translatedTerms, translationLoaded } from "@web/core/l10n/translation";
import { session } from "@web/session";
import { IndexedDB } from "@web/core/utils/indexed_db";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import { session } from "@web/session";
import { Component, markup, xml } from "@odoo/owl";
const { DateTime } = luxon;
@ -51,6 +51,7 @@ async function mockLang(lang) {
await makeMockEnv();
}
test.tags("headless");
test("lang is given by the user context", async () => {
onRpc("/web/webclient/translations", (request) => {
const urlParams = new URLSearchParams(new URL(request.url).search);
@ -60,6 +61,7 @@ test("lang is given by the user context", async () => {
expect.verifySteps(["fr_FR"]);
});
test.tags("headless");
test("lang is given by an attribute on the DOM root node", async () => {
serverState.lang = null;
onRpc("/web/webclient/translations", (request) => {
@ -74,20 +76,17 @@ test("lang is given by an attribute on the DOM root node", async () => {
expect.verifySteps(["fr_FR"]);
});
test.tags("headless");
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(request);
},
{ pure: true }
);
onRpc("/get_translations", function (request) {
expect.step("/get_translations");
return this.loadTranslations(request);
});
await makeMockEnv();
expect.verifySteps(["/get_translations"]);
});
test("can translate a text node", async () => {
@ -340,56 +339,67 @@ test("can lazy translate", async () => {
expect("#main").toHaveText("Bonjour");
});
test.tags("headless");
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.tags("headless");
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.tags("headless");
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.tags("headless");
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.tags("headless");
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.tags("headless");
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.tags("headless");
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.tags("headless");
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.tags("headless");
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.tags("headless");
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.tags("headless");
test("_t fills the format specifiers in translated terms with its extra arguments", async () => {
patchTranslations({
web: {
@ -400,6 +410,7 @@ test("_t fills the format specifiers in translated terms with its extra argument
expect(translatedStr).toBe("Échéance dans 513 jours");
});
test.tags("headless");
test("_t fills the format specifiers in translated terms with formatted lists", async () => {
await mockLang("fr_FR");
patchTranslations({
@ -418,6 +429,7 @@ test("_t fills the format specifiers in translated terms with formatted lists",
expect(translatedStr2).toBe("Échéance dans 30, 60 et 90 jours pour Mitchell");
});
test.tags("headless");
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);
@ -429,6 +441,7 @@ test("_t fills the format specifiers in lazy translated terms with its extra arg
expect(translatedStr.toString()).toBe("Échéance dans 513 jours");
});
describe.tags("headless");
describe("_t with markups", () => {
test("non-markup values are escaped", () => {
translatedTerms[translationLoaded] = true;

View file

@ -946,3 +946,39 @@ test("showDebugInput = false", async () => {
await openModelFieldSelectorPopover();
expect(".o_model_field_selector_debug").toHaveCount(0);
});
test("models with a m2o of the same name should show the correct page data", async () => {
class Cat extends models.Model {
cat_name = fields.Char();
link = fields.Many2one({ relation: "dog" });
}
class Dog extends models.Model {
dog_name = fields.Char();
link = fields.Many2one({ relation: "fish" });
}
class Fish extends models.Model {
fish_name = fields.Char();
}
defineModels([Cat, Dog, Fish]);
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "link",
resModel: "cat",
},
});
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector_popover_relation_icon").click();
expect(getDisplayedFieldNames()).toEqual([
"Created on",
"Display name",
"Dog name",
"Id",
"Last Modified on",
"Link",
]);
});

View file

@ -215,3 +215,15 @@ 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");
});
test("model_selector: autofocus", async () => {
await mountWithCleanup(ModelSelector, {
props: {
models: ["model.1"],
autofocus: true,
onModelSelected: () => {},
},
});
const input = queryAll("input.o-autocomplete--input")[0];
expect(input).toBe(document.activeElement);
});

View file

@ -112,3 +112,21 @@ test("test name_and_signature widget update signmode with onSignatureChange prop
await contains(".o_web_sign_draw_button").click();
expect(currentSignMode).toBe("draw");
});
test("test name_and_signature widget with non-breaking spaces", async function () {
const props = {
signature: { name: "Non Breaking Spaces" },
};
const res = await mountWithCleanup(NameAndSignature, { props });
expect(res.getCleanedName()).toBe("Non Breaking Spaces");
});
test("test name_and_signature widget with non-breaking spaces and initials mode", async function () {
const props = {
signature: { name: "Non Breaking Spaces" },
signatureType: "initial",
};
const res = await mountWithCleanup(NameAndSignature, { props });
expect(res.getCleanedName()).toBe("N.B.S.");
});

View file

@ -1,5 +1,5 @@
import { Component, onMounted, useState, xml } from "@odoo/owl";
import { Navigator, useNavigation } from "@web/core/navigation/navigation";
import { ACTIVE_ELEMENT_CLASS, Navigator, useNavigation } from "@web/core/navigation/navigation";
import { useAutofocus } from "@web/core/utils/hooks";
import { describe, destroy, expect, test } from "@odoo/hoot";
import {
@ -312,3 +312,91 @@ test("non-navigable dom update does NOT cause re-focus", async () => {
expect(".test-non-navigable").toHaveCount(1);
expect(".one").not.toBeFocused();
});
test("mousehover only set active if navigation is availible", async () => {
class Parent extends Component {
static props = [];
static template = xml`
<div class="container" t-ref="containerRef">
<button class="o-navigable one">target one</button>
<button class="o-navigable two">target two</button>
</div>
`;
setup() {
this.navigation = useNavigation("containerRef");
}
}
const component = await mountWithCleanup(Parent);
expect(".one").not.toBeFocused();
expect(".two").not.toBeFocused();
expect(component.navigation.activeItem).toBe(null);
await hover(".one");
expect(component.navigation.activeItem).toBe(null);
await hover(".two");
expect(component.navigation.activeItem).toBe(null);
await click(".one");
expect(".one").toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(".two").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(component.navigation.activeItem.target).toBe(queryOne(".one"));
await hover(".two");
expect(".one").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(".two").toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(component.navigation.activeItem.target).toBe(queryOne(".two"));
});
test("active item is unset when focusing out", async () => {
class Parent extends Component {
static props = [];
static template = xml`
<button class="outside">outside</button>
<div class="container" t-ref="containerRef">
<button class="o-navigable one">target one</button>
<button class="o-navigable two">target two</button>
</div>
`;
setup() {
this.navigation = useNavigation("containerRef");
}
}
const component = await mountWithCleanup(Parent);
await click(".one");
expect(".one").toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(".two").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(component.navigation.activeItem.target).toEqual(queryOne(".one"));
await click(".outside");
expect(".one").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(".two").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(component.navigation.activeItem).toBe(null);
});
test("set focused element as active item", async () => {
class Parent extends Component {
static props = [];
static template = xml`
<div class="container" t-ref="containerRef">
<input class="o-navigable one" id="input" t-ref="autofocus"/>
<button class="o-navigable two">target two</button>
<button class="o-navigable three">target three</button>
</div>
`;
setup() {
this.inputRef = useAutofocus();
this.navigation = useNavigation("containerRef");
}
}
const component = await mountWithCleanup(Parent);
expect(component.inputRef.el).toBeFocused();
expect(component.navigation.activeItem).not.toBeEmpty();
expect(component.navigation.activeItem.el).toBe(component.inputRef.el);
});

View file

@ -138,10 +138,12 @@ test("trigger a ConnectionLostError when response isn't json parsable", async ()
test("rpc can send additional headers", async () => {
mockFetch((url, settings) => {
expect(settings.headers).toEqual({
"Content-Type": "application/json",
Hello: "World",
});
expect(settings.headers).toEqual(
new Headers([
["Content-Type", "application/json"],
["Hello", "World"],
])
);
return { result: true };
});
await rpc("/test/", null, { headers: { Hello: "World" } });

View file

@ -1,17 +1,24 @@
import { expect, test } from "@odoo/hoot";
import { Deferred, microTick } from "@odoo/hoot-mock";
import { Deferred, describe, expect, microTick, test, tick } from "@odoo/hoot";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
import { RPCCache } from "@web/core/network/rpc_cache";
import { IDBQuotaExceededError, IndexedDB } from "@web/core/utils/indexed_db";
const symbol = Symbol("Promise");
const S_PENDING = Symbol("Promise");
/**
* @param {Promise<any>} promise
*/
function promiseState(promise) {
return Promise.race([promise, Promise.resolve(symbol)]).then(
(value) => (value === symbol ? { status: "pending" } : { status: "fulfilled", value }),
return Promise.race([promise, Promise.resolve(S_PENDING)]).then(
(value) => (value === S_PENDING ? { status: "pending" } : { status: "fulfilled", value }),
(reason) => ({ status: "rejected", reason })
);
}
describe.current.tags("headless");
test("RamCache: can cache a simple call", async () => {
// The fist call to rpcCache.read will save the result on the RamCache.
// The fist call to rpcCache.read saves the result on the RamCache.
// Each next call will retrive the ram cache independently, without executing the fallback
const rpcCache = new RPCCache(
"mockRpc",
@ -86,7 +93,7 @@ test("PersistentCache: can cache a simple call", async () => {
value: { test: 123 },
});
// Simulate a reload (Clear the Ram Cache)
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
const def = new Deferred();
@ -231,7 +238,7 @@ test("IndexedDB Crypt: can cache a simple call", async () => {
value: { test: 123 },
});
// Simulate a reload (Clear the Ram Cache)
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
const def = new Deferred();
@ -326,7 +333,7 @@ test("update callback - Disk Value", async () => {
value: { test: 123 },
});
// Simulate a reload (Clear the Ram Cache)
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
const def = new Deferred();
@ -448,7 +455,7 @@ test("Ram value shouldn't change (update the IndexedDB response)", async () => {
value: { test: 123 },
});
// Simulate a reload (Clear the Ram Cache)
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
@ -533,7 +540,7 @@ test("Changing the result shouldn't force the call to callback with hasChanged (
value: { test: 123 },
});
// Simulate a reload (Clear the Ram Cache)
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
@ -563,11 +570,235 @@ test("Changing the result shouldn't force the call to callback with hasChanged (
def.resolve({ test: 123 });
});
test("DiskCache: multiple consecutive calls, call once fallback", async () => {
// The fist call to rpcCache.read will save the promise to the Ram Cache.
// Each next call (before the end of the first call) will retrive the promise of the first call
// without executing the fallback
// the callback of each call is executed.
test("RamCache (no update): consecutive calls (success)", async () => {
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
rpcCache
.read("table", "key", () => def)
.then((r) => {
expect.step(`first prom resolved with ${r}`);
});
rpcCache
.read("table", "key", () => expect.step("should not be called"))
.then((r) => {
expect.step(`second prom resolved with ${r}`);
});
def.resolve("some value");
await tick();
expect.verifySteps([
"first prom resolved with some value",
"second prom resolved with some value",
]);
});
test("RamCache (no update): consecutive calls and rejected promise", async () => {
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
rpcCache
.read("table", "key", () => def)
.catch((e) => {
expect.step(`first prom rejected ${e.message}`);
});
rpcCache
.read("table", "key", () => expect.step("should not be called"))
.catch((e) => {
expect.step(`second prom rejected ${e.message}`);
});
def.reject(new Error("boom"));
await tick();
expect.verifySteps(["first prom rejected boom", "second prom rejected boom"]);
});
test("RamCache: pending request and call to invalidate", async () => {
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
rpcCache
.read("table", "key", () => {
expect.step("fallback first call");
return def;
})
.then((r) => {
expect.step(`first prom resolved with ${r}`);
});
rpcCache.invalidate();
rpcCache
.read("table", "key", () => {
expect.step("fallback second call");
return Promise.resolve("another value");
})
.then((r) => {
expect.step(`second prom resolved with ${r}`);
});
def.resolve("some value");
await tick();
expect.verifySteps([
"fallback first call",
"fallback second call",
"second prom resolved with another value",
"first prom resolved with some value",
]);
// call again to ensure that the correct value is stored in the cache
rpcCache
.read("table", "key", () => expect.step("should not be called"))
.then((r) => {
expect.step(`third prom resolved with ${r}`);
});
await tick();
expect.verifySteps(["third prom resolved with another value"]);
});
test("RamCache: pending request and call to invalidate, update callbacks", async () => {
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
// populate the cache
rpcCache.read("table", "key", () => {
expect.step("first call: fallback");
return Promise.resolve("initial value");
});
await tick();
expect.verifySteps(["first call: fallback"]);
// read cache again, with update callback
const def = new Deferred();
rpcCache
.read(
"table",
"key",
() => {
expect.step("second call: fallback");
return def;
},
{
callback: (newValue) => expect.step(`second call: callback ${newValue}`),
update: "always",
}
)
.then((r) => expect.step(`second call: resolved with ${r}`));
// read it twice, s.t. there's a pending request
rpcCache
.read(
"table",
"key",
() => {
expect.step("should not be called as there's a pending request");
},
{
callback: (newValue) => expect.step(`third call: callback ${newValue}`),
update: "always",
}
)
.then((r) => {
expect.step(`third call: resolved with ${r}`);
});
await tick();
expect.verifySteps([
"second call: fallback",
"second call: resolved with initial value",
"third call: resolved with initial value",
]);
rpcCache.invalidate();
// sanity check to ensure that cache has been invalidated
rpcCache.read("table", "key", () => {
expect.step("fourth call: fallback");
return Promise.resolve("value after invalidation");
});
expect.verifySteps(["fourth call: fallback"]);
// resolve def => update callbacks of requests 2 and 3 must be called
def.resolve("updated value");
await tick();
expect.verifySteps([
"second call: callback updated value",
"third call: callback updated value",
]);
});
test("RamCache: pending request and call to invalidate, update callbacks in error", async () => {
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const defs = [new Deferred(), new Deferred()];
rpcCache
.read("table", "key", () => {
expect.step("first call: fallback (error)");
return defs[0]; // will be rejected
})
.catch((e) => expect.step(`first call: rejected with ${e}`));
// invalidate cache and read again
rpcCache.invalidate();
rpcCache
.read("table", "key", () => {
expect.step("second call: fallback");
return defs[1];
})
.then((r) => expect.step(`second call: resolved with ${r}`));
await tick();
expect.verifySteps(["first call: fallback (error)", "second call: fallback"]);
// reject first def
defs[0].reject("my_error");
await tick();
expect.verifySteps(["first call: rejected with my_error"]);
// read again, should retrieve same prom as second call which is still pending
rpcCache
.read("table", "key", () => expect.step("should not be called"))
.then((r) => expect.step(`third call: resolved with ${r}`));
await tick();
expect.verifySteps([]);
// read again, should retrieve same prom as second call which is still pending (update "always")
rpcCache
.read("table", "key", () => expect.step("should not be called"), { update: "always" })
.then((r) => expect.step(`fourth call: resolved with ${r}`));
await tick();
expect.verifySteps([]);
// resolve second def
defs[1].resolve("updated value");
await tick();
expect.verifySteps([
"second call: resolved with updated value",
"third call: resolved with updated value",
"fourth call: resolved with updated value",
]);
});
test("DiskCache: multiple consecutive calls, empty cache", async () => {
// The fist call to rpcCache.read saves the promise to the RAM cache.
// Each next call (before the end of the first call) retrieves the same result as the first call
// without executing the fallback.
// The callback of each call is executed.
const rpcCache = new RPCCache(
"mockRpc",
@ -586,7 +817,7 @@ test("DiskCache: multiple consecutive calls, call once fallback", async () => {
},
{
callback: () => {
expect.step("callback " + id++);
expect.step(`callback ${++id}`);
},
}
);
@ -595,10 +826,221 @@ test("DiskCache: multiple consecutive calls, call once fallback", async () => {
rpcCacheRead();
rpcCacheRead();
rpcCacheRead();
rpcCacheRead();
expect.verifySteps(["fallback"]);
def.resolve({ test: 123 });
await microTick();
await tick();
expect.verifySteps(["fallback", "callback 0", "callback 1", "callback 2", "callback 3"]);
expect.verifySteps(["callback 1", "callback 2", "callback 3"]);
});
test("DiskCache: multiple consecutive calls, value already in disk cache", async () => {
// The first call to rpcCache.read saves the promise to the RAM cache.
// Each next call (before the end of the first call) retrieves the same result as the first call.
// Each call receives as value the disk value, then each callback is executed.
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
// fill the cache
await rpcCache.read("table", "key", () => Promise.resolve({ test: 123 }), {
type: "disk",
});
await tick();
expect(rpcCache.indexedDB.mockIndexedDB.table.key.ciphertext).toBe(
`encrypted data:{"test":123}`
);
expect(await promiseState(rpcCache.ramCache.ram.table.key)).toEqual({
status: "fulfilled",
value: { test: 123 },
});
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
const rpcCacheRead = (id) =>
rpcCache.read(
"table",
"key",
() => {
expect.step(`fallback ${id}`);
return def;
},
{
type: "disk",
callback: (result, hasChanged) => {
expect.step(
`callback ${id}: ${JSON.stringify(result)} ${hasChanged ? "(changed)" : ""}`
);
},
}
);
rpcCacheRead(1).then((result) => expect.step("res call 1: " + JSON.stringify(result)));
await tick();
rpcCacheRead(2).then((result) => expect.step("res call 2: " + JSON.stringify(result)));
await tick();
rpcCacheRead(3).then((result) => expect.step("res call 3: " + JSON.stringify(result)));
await tick();
expect.verifySteps([
"fallback 1",
'res call 1: {"test":123}',
'res call 2: {"test":123}',
'res call 3: {"test":123}',
]);
def.resolve({ test: 456 });
await tick();
expect.verifySteps([
'callback 1: {"test":456} (changed)',
'callback 2: {"test":456} (changed)',
'callback 3: {"test":456} (changed)',
]);
});
test("DiskCache: multiple consecutive calls, fallback fails", async () => {
// The first call to rpcCache.read saves the promise to the RAM cache.
// Each next call (before the end of the first call) retrieves the same result as the first call.
// The fallback fails.
// Each call receives as value the disk value, callbacks aren't executed.
expect.errors(1);
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
// fill the cache
await rpcCache.read("table", "key", () => Promise.resolve({ test: 123 }), {
type: "disk",
});
await tick();
expect(rpcCache.indexedDB.mockIndexedDB.table.key.ciphertext).toBe(
`encrypted data:{"test":123}`
);
expect(await promiseState(rpcCache.ramCache.ram.table.key)).toEqual({
status: "fulfilled",
value: { test: 123 },
});
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
const rpcCacheRead = (id) =>
rpcCache.read(
"table",
"key",
() => {
expect.step(`fallback ${id}`);
return def;
},
{
type: "disk",
callback: () => {
expect.step("callback (should not be executed)");
},
}
);
rpcCacheRead(1).then((result) => expect.step("res call 1: " + JSON.stringify(result)));
await tick();
rpcCacheRead(2).then((result) => expect.step("res call 2: " + JSON.stringify(result)));
await tick();
rpcCacheRead(3).then((result) => expect.step("res call 3: " + JSON.stringify(result)));
await tick();
expect.verifySteps([
"fallback 1",
'res call 1: {"test":123}',
'res call 2: {"test":123}',
'res call 3: {"test":123}',
]);
def.reject(new Error("my RPCError"));
await tick();
await tick();
expect.verifySteps([]);
expect.verifyErrors(["my RPCError"]);
});
test("DiskCache: multiple consecutive calls, empty cache, fallback fails", async () => {
// The first call to rpcCache.read saves the promise to the RAM cache. That promise will be
// rejected.
// Each next call (before the end of the first call) retrieves the same result as the first call.
// The fallback fails.
// Each call receives the error.
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
const rpcCacheRead = (id) =>
rpcCache.read(
"table",
"key",
() => {
expect.step(`fallback ${id}`);
return def;
},
{
type: "disk",
callback: () => {
expect.step("callback (should not be executed)");
},
}
);
rpcCacheRead(1).catch((error) => expect.step(`error call 1: ${error.message}`));
await tick();
rpcCacheRead(2).catch((error) => expect.step(`error call 2: ${error.message}`));
await tick();
rpcCacheRead(3).catch((error) => expect.step(`error call 3: ${error.message}`));
await tick();
expect.verifySteps(["fallback 1"]);
def.reject(new Error("my RPCError"));
await tick();
expect.verifySteps([
"error call 1: my RPCError",
"error call 2: my RPCError",
"error call 3: my RPCError",
]);
});
test("DiskCache: write throws an IDBQuotaExceededError", async () => {
patchWithCleanup(IndexedDB.prototype, {
deleteDatabase() {
expect.step("delete db");
},
write() {
expect.step("write");
return Promise.reject(new IDBQuotaExceededError());
},
});
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const fallback = () => {
expect.step(`fallback`);
return Promise.resolve("value");
};
await rpcCache.read("table", "key", fallback, { type: "disk" });
await expect.waitForSteps(["fallback", "write", "delete db"]);
});

View file

@ -354,3 +354,64 @@ test("icons can be given for each page tab", async () => {
expect(".nav-item:nth-child(3) i").toHaveClass("fa-pencil");
expect(".nav-item:nth-child(3)").toHaveText("page3");
});
test("switch notebook page after async work", async () => {
let { promise, resolve } = Promise.withResolvers();
class Page extends Component {
static template = xml`<h3>Coucou</h3>`;
static props = ["*"];
}
class Parent extends Component {
static template = xml`<Notebook pages="this.pages" onWillActivatePage="() => this.onWillActivatePage()"/>`;
static components = { Notebook };
static props = ["*"];
setup() {
this.pages = [
{
Component: Page,
index: 1,
title: "Page 1",
},
{
Component: Page,
index: 2,
title: "Page 2",
},
];
}
onWillActivatePage() {
return promise;
}
}
await mountWithCleanup(Parent);
const h3Capture1 = queryFirst("h3");
expect(h3Capture1).toBeInstanceOf(HTMLElement);
await click(".o_notebook_headers .nav-item:nth-child(2) a");
await animationFrame();
// async work is not finished
const h3Capture2 = queryFirst("h3");
expect(h3Capture2).toBe(h3Capture1);
resolve(true);
await animationFrame();
// async work completed successfully
const h3Capture3 = queryFirst("h3");
expect(h3Capture3).toBeInstanceOf(HTMLElement);
expect(h3Capture3).not.toBe(h3Capture1);
({ promise, resolve } = Promise.withResolvers());
await click(".o_notebook_headers .nav-item:nth-child(1) a");
await animationFrame();
// async work is not finished
const h3Capture4 = queryFirst("h3");
expect(h3Capture4).toBe(h3Capture3);
resolve(false);
await animationFrame();
// async work resolved with false, preventing the page change
const h3Capture5 = queryFirst("h3");
expect(h3Capture5).toBe(h3Capture3);
});

View file

@ -1,17 +1,28 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { beforeEach, expect, getFixture, test } from "@odoo/hoot";
import { queryOne, queryRect, resize, scroll, waitFor } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, useRef, xml } from "@odoo/owl";
import { defineStyle, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { Component, useRef, useState, xml } from "@odoo/owl";
import {
contains,
defineStyle,
mountWithCleanup,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { Popover } from "@web/core/popover/popover";
import { usePopover } from "@web/core/popover/popover_hook";
import { patch } from "@web/core/utils/patch";
class Content extends Component {
static props = ["*"];
static template = xml`<div id="popover">Popover Content</div>`;
}
beforeEach(() => {
patchWithCleanup(Popover.defaultProps, {
animation: false,
arrow: false,
});
});
test("popover can have custom class", async () => {
await mountWithCleanup(Popover, {
props: {
@ -238,7 +249,7 @@ test("within iframe", async () => {
close: () => {},
target: popoverTarget,
component: Content,
animation: false,
arrow: true,
onPositioned: (_, { direction }) => {
expect.step(direction);
},
@ -262,7 +273,7 @@ test("within iframe", async () => {
await scroll(popoverTarget.ownerDocument.documentElement, { y: 100 }, { scrollable: false });
await animationFrame();
expect.verifySteps(["bottom"]);
expect.verifySteps(["bottom", "bottom"]);
popoverBox = comp.popoverRef.el.getBoundingClientRect();
expectedTop -= 100;
expect(Math.floor(popoverBox.top)).toBe(Math.floor(expectedTop));
@ -359,7 +370,12 @@ test("popover with arrow and onPositioned", async () => {
},
});
expect.verifySteps(["onPositioned (from override)", "onPositioned (from props)"]);
expect.verifySteps([
"onPositioned (from override)",
"onPositioned (from props)", // On mounted
"onPositioned (from override)",
"onPositioned (from props)", // arrow repositionning -> triggers resize observer
]);
expect(".o_popover").toHaveClass("o_popover popover mw-100 bs-popover-auto");
expect(".o_popover").toHaveAttribute("data-popper-placement", "bottom");
expect(".o_popover > .popover-arrow").toHaveClass("position-absolute z-n1");
@ -389,10 +405,50 @@ test("popover closes when navigating", async () => {
expect.verifySteps(["HTML", "close"]);
});
test("popover position is updated when the content dimensions change", async () => {
class DynamicContent extends Component {
setup() {
this.state = useState({
showMore: false,
});
}
static props = ["*"];
static template = xml`<div id="popover">
Click on this <button t-on-click="() => this.state.showMore = true">button</button> to read more
<span t-if="state.showMore">
This tooltip gives your more information on this topic!
</span>
</div>`;
}
await mountWithCleanup(/* xml */ `
<div class="popover-target" style="width: 50px; height: 50px;" />
`);
await mountWithCleanup(Popover, {
props: {
close: () => {},
target: queryOne(".popover-target"),
position: "bottom-start",
component: DynamicContent,
onPositioned() {
expect.step("onPositioned");
},
},
});
expect(".o_popover").toHaveCount(1);
await runAllTimers();
expect.verifySteps(["onPositioned", "onPositioned"]);
await contains("#popover button").click();
expect("#popover span").toHaveCount(1);
await expect.waitForSteps(["onPositioned"]);
});
test("arrow follows target and can get sucked", async () => {
let container;
patch(Popover, { animationTime: 0 });
patch(Popover.prototype, {
patchWithCleanup(Popover.defaultProps, { arrow: true });
patchWithCleanup(Popover.prototype, {
get positioningOptions() {
return {
...super.positioningOptions,
@ -472,3 +528,29 @@ test("arrow follows target and can get sucked", async () => {
expect(".popover-arrow").toHaveClass("sucked");
expect(".popover-arrow").not.toBeVisible();
});
test("popover can animate", async () => {
patchWithCleanup(window.Element.prototype, {
animate() {
expect(this).toHaveClass("o_popover");
expect.step("animated");
return super.animate(...arguments);
},
});
await mountWithCleanup(Popover, {
props: {
close: () => {},
target: getFixture(),
animation: true,
component: Content,
},
});
expect(".o_popover").toHaveCount(1);
await animationFrame();
await runAllTimers();
expect.verifySteps(["animated"]);
});

View file

@ -949,9 +949,10 @@ const CONTAINER_STYLE_MAP = {
w125: { width: "125px" }, // width of popper + 1/2 target
};
function getRepositionTest(from, to, containerStyleChanges) {
function getRepositionTest(from, to, containerStyleChanges, extendedFlipping = false) {
return async () => {
const TestComp = getTestComponent({
extendedFlipping,
position: from,
onPositioned: (el, { direction, variant }) => {
expect.step(`${direction}-${variant}`);
@ -975,156 +976,219 @@ function getRepositionTest(from, to, containerStyleChanges) {
}
// -----------------------------------------------------------------------------
test("reposition from top-start to top", getRepositionTest("top-start", "bottom-start", "top"));
test(
"reposition from top-start to bottom-start",
getRepositionTest("top-start", "bottom-start", "top")
);
test(
"reposition from top-start to bottom-end",
"reposition from top-start to top right",
getRepositionTest("top-start", "bottom-end", "top right")
);
test(
"reposition from top-start to top-start",
"reposition from top-start to slimfit bottom",
getRepositionTest("top-start", "top-start", "slimfit bottom")
);
test("reposition from top-start to top-end", getRepositionTest("top-start", "top-end", "right"));
test("reposition from top-start to right", getRepositionTest("top-start", "top-end", "right"));
// -----------------------------------------------------------------------------
test("reposition from top-middle to top", getRepositionTest("top-middle", "bottom-middle", "top"));
test(
"reposition from top-middle to bottom-middle",
getRepositionTest("top-middle", "bottom-middle", "top")
);
test(
"reposition from top-middle to top-middle",
"reposition from top-middle to slimfit bottom",
getRepositionTest("top-middle", "top-middle", "slimfit bottom")
);
// -----------------------------------------------------------------------------
test(
"reposition from top-end to bottom-start",
"reposition from top-end to top left",
getRepositionTest("top-end", "bottom-start", "top left")
);
test("reposition from top-end to bottom-end", getRepositionTest("top-end", "bottom-end", "top"));
test("reposition from top-end to top-start", getRepositionTest("top-end", "top-start", "left"));
test("reposition from top-end to top", getRepositionTest("top-end", "bottom-end", "top"));
test("reposition from top-end to left", getRepositionTest("top-end", "top-start", "left"));
test(
"reposition from top-end to top-end",
"reposition from top-end to slimfit bottom",
getRepositionTest("top-end", "top-end", "slimfit bottom")
);
// -----------------------------------------------------------------------------
test("reposition from left-start to left", getRepositionTest("left-start", "right-start", "left"));
test(
"reposition from left-start to right-start",
getRepositionTest("left-start", "right-start", "left")
);
test(
"reposition from left-start to right-end",
"reposition from left-start to left bottom",
getRepositionTest("left-start", "right-end", "left bottom")
);
test(
"reposition from left-start to left-start",
"reposition from left-start to slimfit top",
getRepositionTest("left-start", "left-start", "slimfit top")
);
test(
"reposition from left-start to left-end",
getRepositionTest("left-start", "left-end", "bottom")
);
test("reposition from left-start to bottom", getRepositionTest("left-start", "left-end", "bottom"));
// -----------------------------------------------------------------------------
test(
"reposition from left-middle to right-middle",
"reposition from left-middle to left",
getRepositionTest("left-middle", "right-middle", "left")
);
test(
"reposition from left-middle to left-middle",
"reposition from left-middle to slimfit bottom",
getRepositionTest("left-middle", "left-middle", "slimfit bottom")
);
// -----------------------------------------------------------------------------
test(
"reposition from left-end to right-start",
"reposition from left-end to left top",
getRepositionTest("left-end", "right-start", "left top")
);
test("reposition from left-end to right-end", getRepositionTest("left-end", "right-end", "left"));
test("reposition from left-end to left-start", getRepositionTest("left-end", "left-start", "top"));
test("reposition from left-end to left", getRepositionTest("left-end", "right-end", "left"));
test("reposition from left-end to top", getRepositionTest("left-end", "left-start", "top"));
test(
"reposition from left-end to left-end",
"reposition from left-end to slimfit bottom",
getRepositionTest("left-end", "left-end", "slimfit bottom")
);
// -----------------------------------------------------------------------------
test(
"reposition from bottom-start to bottom-start",
"reposition from bottom-start to slimfit top",
getRepositionTest("bottom-start", "bottom-start", "slimfit top")
);
test(
"reposition from bottom-start to bottom-end",
"reposition from bottom-start to right",
getRepositionTest("bottom-start", "bottom-end", "right")
);
test(
"reposition from bottom-start to top-start",
"reposition from bottom-start to bottom",
getRepositionTest("bottom-start", "top-start", "bottom")
);
test(
"reposition from bottom-start to top-end",
"reposition from bottom-start to bottom right",
getRepositionTest("bottom-start", "top-end", "bottom right")
);
// -----------------------------------------------------------------------------
test(
"reposition from bottom-middle to bottom-middle",
"reposition from bottom-middle to slimfit top",
getRepositionTest("bottom-middle", "bottom-middle", "slimfit top")
);
test(
"reposition from bottom-middle to top-middle",
"reposition from bottom-middle to bottom",
getRepositionTest("bottom-middle", "top-middle", "bottom")
);
// -----------------------------------------------------------------------------
test("reposition from bottom-end to left", getRepositionTest("bottom-end", "bottom-start", "left"));
test(
"reposition from bottom-end to bottom-start",
getRepositionTest("bottom-end", "bottom-start", "left")
);
test(
"reposition from bottom-end to bottom-end",
"reposition from bottom-end to slimfit top",
getRepositionTest("bottom-end", "bottom-end", "slimfit top")
);
test(
"reposition from bottom-end to top-start",
"reposition from bottom-end to bottom left",
getRepositionTest("bottom-end", "top-start", "bottom left")
);
test("reposition from bottom-end to top-end", getRepositionTest("bottom-end", "top-end", "bottom"));
test("reposition from bottom-end to bottom", getRepositionTest("bottom-end", "top-end", "bottom"));
// -----------------------------------------------------------------------------
test(
"reposition from right-start to right-start",
"reposition from right-start to slimfit top",
getRepositionTest("right-start", "right-start", "slimfit top")
);
test(
"reposition from right-start to right-end",
"reposition from right-start to bottom",
getRepositionTest("right-start", "right-end", "bottom")
);
test(
"reposition from right-start to left-start",
"reposition from right-start to right",
getRepositionTest("right-start", "left-start", "right")
);
test(
"reposition from right-start to left-end",
"reposition from right-start to right bottom",
getRepositionTest("right-start", "left-end", "right bottom")
);
// -----------------------------------------------------------------------------
test(
"reposition from right-middle to right-middle",
"reposition from right-middle to slimfit bottom",
getRepositionTest("right-middle", "right-middle", "slimfit bottom")
);
test(
"reposition from right-middle to left-middle",
"reposition from right-middle to right",
getRepositionTest("right-middle", "left-middle", "right")
);
// -----------------------------------------------------------------------------
test("reposition from right-end to top", getRepositionTest("right-end", "right-start", "top"));
test(
"reposition from right-end to right-start",
getRepositionTest("right-end", "right-start", "top")
);
test(
"reposition from right-end to right-end",
"reposition from right-end to slimfit bottom",
getRepositionTest("right-end", "right-end", "slimfit bottom")
);
test(
"reposition from right-end to left-start",
"reposition from right-end to right top",
getRepositionTest("right-end", "left-start", "right top")
);
test("reposition from right-end to left-end", getRepositionTest("right-end", "left-end", "right"));
test("reposition from right-end to right", getRepositionTest("right-end", "left-end", "right"));
// Reposition with all flipping directions allowed
test(
"extended reposition from top-start to slimfit bottom",
getRepositionTest("top-start", "center-start", "slimfit bottom", true)
);
test(
"extended reposition from top-start to right",
getRepositionTest("top-start", "top-end", "right", true)
);
test(
"extended reposition from top-middle to slimfit bottom",
getRepositionTest("top-middle", "center-middle", "slimfit bottom", true)
);
test(
"extended reposition from top-end to left",
getRepositionTest("top-end", "top-start", "left", true)
);
test(
"extended reposition from top-end to slimfit bottom",
getRepositionTest("top-end", "center-end", "slimfit bottom", true)
);
test(
"extended reposition from left-start to slimfit top",
getRepositionTest("left-start", "center-start", "slimfit top", true)
);
test(
"extended reposition from left-start to bottom",
getRepositionTest("left-start", "left-end", "bottom", true)
);
test(
"extended reposition from left-middle to slimfit bottom",
getRepositionTest("left-middle", "center-middle", "slimfit bottom", true)
);
test(
"extended reposition from left-end to top",
getRepositionTest("left-end", "left-start", "top", true)
);
test(
"extended reposition from left-end to slimfit bottom",
getRepositionTest("left-end", "center-end", "slimfit bottom", true)
);
test(
"extended reposition from bottom-start to slimfit top",
getRepositionTest("bottom-start", "center-start", "slimfit top", true)
);
test(
"extended reposition from bottom-start to right",
getRepositionTest("bottom-start", "bottom-end", "right", true)
);
test(
"extended reposition from bottom-middle to slimfit top",
getRepositionTest("bottom-middle", "center-middle", "slimfit top", true)
);
test(
"extended reposition from bottom-end to left",
getRepositionTest("bottom-end", "bottom-start", "left", true)
);
test(
"extended reposition from bottom-end to slimfit top",
getRepositionTest("bottom-end", "center-end", "slimfit top", true)
);
test(
"extended reposition from right-start to slimfit top",
getRepositionTest("right-start", "center-start", "slimfit top", true)
);
test(
"extended reposition from right-start to bottom",
getRepositionTest("right-start", "right-end", "bottom", true)
);
test(
"extended reposition from right-middle to slimfit bottom",
getRepositionTest("right-middle", "center-middle", "slimfit bottom", true)
);
test(
"extended reposition from right-end to top",
getRepositionTest("right-end", "right-start", "top", true)
);
test(
"extended reposition from right-end to slimfit bottom",
getRepositionTest("right-end", "center-end", "slimfit bottom", true)
);
function getFittingTest(position, styleAttribute) {
return async () => {

View file

@ -1,6 +1,5 @@
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 { getService, makeMockEnv, onRpc, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
@ -17,8 +16,8 @@ const mountManifestLink = (href) => {
test("PWA service fetches the manifest found in the page", async () => {
await makeMockEnv();
mountManifestLink("/web/manifest.webmanifest");
mockFetch((route) => {
expect.step(route);
onRpc("/*", (request) => {
expect.step(new URL(request.url).pathname);
return { name: "Odoo PWA" };
});
const pwaService = await getService("pwa");
@ -38,8 +37,8 @@ test("PWA installation process", async () => {
browser.BeforeInstallPromptEvent = beforeInstallPromptEvent;
await makeMockEnv();
mountManifestLink("/web/manifest.scoped_app_manifest");
mockFetch((route) => {
expect.step(route);
onRpc("/*", (request) => {
expect.step(new URL(request.url).pathname);
return { name: "My App", scope: "/scoped_app/myApp", start_url: "/scoped_app/myApp" };
});
patchWithCleanup(browser.localStorage, {

View file

@ -88,9 +88,11 @@ test("handles resize handle at start in fixed position", async () => {
x: window.innerWidth - 200,
},
});
expect(resizablePanelEl).toHaveRect({
width: 100 + queryRect(".o_resizable_panel_handle").width / 2,
});
const panelExpectedWidth = 100 + queryRect(".o_resizable_panel_handle").width / 2;
expect(queryRect(resizablePanelEl).width).toBeWithin(
panelExpectedWidth,
panelExpectedWidth + 1
);
});
test("resizing the window adapts the panel", async () => {

View file

@ -1,6 +1,6 @@
import { describe, expect, getFixture, test } from "@odoo/hoot";
import { click, on } from "@odoo/hoot-dom";
import { tick } from "@odoo/hoot-mock";
import { mockMatchMedia, tick } from "@odoo/hoot-mock";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
@ -1677,6 +1677,24 @@ describe("History", () => {
});
});
describe("Scoped apps", () => {
test("url location is changed to /odoo if the client is not used in a standalone scoped app", async () => {
Object.assign(browser.location, { pathname: "/scoped_app/some-path" });
createRouter();
router.pushState({ app_name: "some_app", path: "scoped_app/some_path" });
await tick();
expect(browser.location.href).toBe("https://www.hoot.test/odoo/some-path?app_name=some_app&path=scoped_app%2Fsome_path");
});
test("url location is preserved as /scoped_app if the client is used in a standalone scoped app", async () => {
mockMatchMedia({ ["display-mode"]: "standalone" });
Object.assign(browser.location, { pathname: "/scoped_app/some-path" });
createRouter();
router.pushState({ app_name: "some_app", path: "scoped_app/some_path" });
await tick();
expect(browser.location.href).toBe("https://www.hoot.test/scoped_app/some-path?app_name=some_app&path=scoped_app%2Fsome_path");
});
})
describe("Retrocompatibility", () => {
test("parse an url with hash (key/values)", async () => {
Object.assign(browser.location, { pathname: "/web" });

View file

@ -729,7 +729,7 @@ test("When multiSelect is enable, value is an array of values, multiple choices
// Select second choice
await open();
expect(".o_select_menu_item:nth-of-type(1).active").toHaveCount(1);
expect(".o_select_menu_item:nth-of-type(1).selected").toHaveCount(1);
await editSelectMenu(".o_select_menu input", { index: 1 });
expect.verifySteps([["a", "b"]]);
@ -737,7 +737,7 @@ test("When multiSelect is enable, value is an array of values, multiple choices
expect(".o_select_menu .o_tag_badge_text").toHaveCount(2);
await open();
expect(".o_select_menu_item.active").toHaveCount(2);
expect(".o_select_menu_item.selected").toHaveCount(2);
});
test("When multiSelect is enable, allow deselecting elements by clicking the selected choices inside the dropdown or by clicking the tags", async () => {
@ -778,7 +778,7 @@ test("When multiSelect is enable, allow deselecting elements by clicking the sel
expect(".o_select_menu .o_tag_badge_text").toHaveText("B");
await open();
expect(".o_select_menu_item.active").toHaveCount(1);
expect(".o_select_menu_item.selected").toHaveCount(1);
await click(".o_tag .o_delete");
await animationFrame();

View file

@ -386,3 +386,40 @@ test("touch rendering - tap-to-show", async () => {
await animationFrame();
expect(".o_popover").toHaveCount(0);
});
test.tags("desktop");
test("tooltip from and to child element", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<div class="no-tooltip">space</div>
<div class="p-5" data-tooltip="hello">
<button>Action</button>
</div>`;
}
await mountWithCleanup(MyComponent);
expect(".o_popover").toHaveCount(0);
await pointerDown("div[data-tooltip]");
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(".o_popover").toHaveCount(1);
const popover = queryOne(".o_popover");
await pointerDown("button");
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(".o_popover").toHaveCount(1);
expect(queryOne(".o_popover")).toBe(popover);
await pointerDown("div[data-tooltip]");
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(queryOne(".o_popover")).toBe(popover);
await pointerDown(".no-tooltip");
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(".o_popover").toHaveCount(0);
});

View file

@ -157,6 +157,29 @@ test("do not become UI active element if no element to focus", async () => {
expect(getService("ui").activeElement).toBe(document);
});
test("become UI active element if no element to focus but the container is focusable", async () => {
class MyComponent extends Component {
static template = xml`
<div>
<h1>My Component</h1>
<input type="text" placeholder="outerUIActiveElement"/>
<div id="idActiveElement" t-ref="delegatedRef" tabindex="-1">
<div>
<span> No focus element </span>
</div>
</div>
</div>
`;
static props = ["*"];
setup() {
useActiveElement("delegatedRef");
}
}
await mountWithCleanup(MyComponent);
expect(getService("ui").activeElement).toBe(queryOne("#idActiveElement"));
});
test("UI active element: trap focus - first or last tabable changes", async () => {
class MyComponent extends Component {
static template = xml`

View file

@ -2,6 +2,7 @@ import { describe, expect, test } from "@odoo/hoot";
import { allowTranslations } from "@web/../tests/web_test_helpers";
import { humanSize } from "@web/core/utils/binary";
import { resizeBlobImg } from "@web/core/utils/files";
describe.current.tags("headless");
@ -12,3 +13,35 @@ test("humanSize", () => {
expect(humanSize(2048)).toBe("2.00 Kb");
expect(humanSize(2645000)).toBe("2.52 Mb");
});
test("resize image", async () => {
function buildblobImage(w, h) {
return new Promise((resolve) => {
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "rgb(200 0 0)";
ctx.fillRect(0, 0, w / 2, h / 2);
canvas.toBlob(resolve);
});
}
function blobTob64(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
resolve(reader.result);
};
});
}
const bigBlobImg = await buildblobImage(256, 256);
const smallBlobImg = await buildblobImage(64, 64);
const resized = await resizeBlobImg(bigBlobImg, { width: 64, height: 64 });
const smallBlobImgB64 = await blobTob64(smallBlobImg);
expect(smallBlobImgB64).not.toBeEmpty();
expect(await blobTob64(resized)).toBe(smallBlobImgB64);
});

View file

@ -433,3 +433,39 @@ test("Focusing is not lost after clicking", async () => {
await contains(".item").click();
expect(".item").toBeFocused();
});
test("allowDisconnected option", async () => {
class List extends Component {
static template = xml`
<div t-ref="root" class="root">
<button class="handle" t-if="state.hasHandle">Handle</button>
<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 h-100" />
</ul>
</div>`;
static props = ["*"];
setup() {
this.state = useState({ hasHandle: true });
useDraggable({
ref: useRef("root"),
elements: ".handle",
allowDisconnected: true,
onDragStart: () => {
expect.step("start");
this.state.hasHandle = false;
},
onDragEnd: () => expect.step("end"),
onDrop: () => expect.step("drop"), // should be called as allowDisconnected
});
}
}
await mountWithCleanup(List);
const { moveTo, drop } = await contains(".handle").drag();
expect.verifySteps(["start"]);
await animationFrame();
expect(".handle").toHaveCount(0);
await moveTo(".item:nth-child(2)");
await drop();
expect.verifySteps(["drop", "end"]);
});

View file

@ -1,8 +1,6 @@
import { describe, expect, test } from "@odoo/hoot";
import { htmlEscape, markup } from "@odoo/owl";
const Markup = markup().constructor;
import { describe, expect, test } from "@odoo/hoot";
import {
createDocumentFragmentFromContent,
createElementWithContent,
@ -18,6 +16,8 @@ import {
setElementContent,
} from "@web/core/utils/html";
const Markup = markup().constructor;
describe.current.tags("headless");
test("createDocumentFragmentFromContent escapes text", () => {
@ -47,6 +47,9 @@ test("highlightText", () => {
expect(highlightText("b", "abcb", "hl").toString()).toBe(
'a<span class="hl">b</span>c<span class="hl">b</span>'
);
expect(highlightText("b", "abbc", "hl").toString()).toBe(
'a<span class="hl">b</span><span class="hl">b</span>c'
);
expect(highlightText("b", "<p>ab</p>", "hl").toString()).toBe(
'&lt;p&gt;a<span class="hl">b</span>&lt;/p&gt;'
);
@ -111,9 +114,7 @@ test("htmlSprintf escapes list params", () => {
markup`<span>test 1</span>`,
`<span>test 2</span>`
);
expect(res.toString()).toBe(
"<p><span>test 1</span>,&lt;span&gt;test 2&lt;/span&gt;</p>undefined"
);
expect(res.toString()).toBe("<p><span>test 1</span></p>&lt;span&gt;test 2&lt;/span&gt;");
expect(res).toBeInstanceOf(Markup);
});
@ -299,10 +300,10 @@ test("htmlReplaceAll with html/text does not find, keeps all", () => {
test("htmlReplace/htmlReplaceAll only accept functions replacement when search is a RegExp", () => {
expect(() => htmlReplace("test", /test/, "$1")).toThrow(
"htmlReplace: replacement must be a function when search is a RegExp."
"htmlReplace: replacer must be a function when search is a RegExp."
);
expect(() => htmlReplaceAll("test", /test/, "$1")).toThrow(
"htmlReplaceAll: replacement must be a function when search is a RegExp."
"htmlReplaceAll: replacer must be a function when search is a RegExp."
);
});
@ -324,9 +325,9 @@ test("odoomark", () => {
expect(odoomark("**test** something else **test**").toString()).toBe(
"<b>test</b> something else <b>test</b>"
);
expect(odoomark("--test--").toString()).toBe("<span class='text-muted'>test</span>");
expect(odoomark("--test--").toString()).toBe(`<span class="text-muted">test</span>`);
expect(odoomark("--test-- something else --test--").toString()).toBe(
"<span class='text-muted'>test</span> something else <span class='text-muted'>test</span>"
`<span class="text-muted">test</span> something else <span class="text-muted">test</span>`
);
expect(odoomark("`test`").toString()).toBe(
`<span class="o_tag position-relative d-inline-flex align-items-center mw-100 o_badge badge rounded-pill lh-1 o_tag_color_0">test</span>`
@ -337,7 +338,7 @@ test("odoomark", () => {
expect(odoomark("test\ttest2").toString()).toBe(
`test<span style="margin-left: 2em"></span>test2`
);
expect(odoomark("test\ntest2").toString()).toBe("test<br/>test2");
expect(odoomark("test\ntest2").toString()).toBe("test<br>test2");
expect(odoomark("<p>**test**</p>").toString()).toBe("&lt;p&gt;<b>test</b>&lt;/p&gt;");
expect(odoomark(markup`<p>**test**</p>`).toString()).toBe("<p><b>test</b></p>");
});

View file

@ -252,6 +252,42 @@ test("floatIsZero", () => {
});
describe("formatFloat", () => {
test("precision", () => {
patchWithCleanup(localization, {
decimalPoint: ".",
grouping: [3, 0],
thousandsSep: ",",
});
let options = {};
expect(formatFloat(3, options)).toBe("3.00");
expect(formatFloat(3.1, options)).toBe("3.10");
expect(formatFloat(3.12, options)).toBe("3.12");
expect(formatFloat(3.129, options)).toBe("3.13");
options = { digits: [15, 3] };
expect(formatFloat(3, options)).toBe("3.000");
expect(formatFloat(3.1, options)).toBe("3.100");
expect(formatFloat(3.123, options)).toBe("3.123");
expect(formatFloat(3.1239, options)).toBe("3.124");
options = { minDigits: 3 };
expect(formatFloat(0, options)).toBe("0.000");
expect(formatFloat(3, options)).toBe("3.000");
expect(formatFloat(3.1, options)).toBe("3.100");
expect(formatFloat(3.123, options)).toBe("3.123");
expect(formatFloat(3.1239, options)).toBe("3.1239");
expect(formatFloat(3.1231239, options)).toBe("3.123124");
expect(formatFloat(1234567890.1234567890, options)).toBe("1,234,567,890.12346");
options = { minDigits: 3, digits: [15, 4] };
expect(formatFloat(3, options)).toBe("3.000");
expect(formatFloat(3.1, options)).toBe("3.100");
expect(formatFloat(3.123, options)).toBe("3.123");
expect(formatFloat(3.1239, options)).toBe("3.1239");
expect(formatFloat(3.1234567, options)).toBe("3.1235");
});
test("localized", () => {
patchWithCleanup(localization, {
decimalPoint: ".",
@ -343,7 +379,7 @@ describe("formatFloat", () => {
expect(formatFloat(value, options)).toBe(resHuman);
});
Object.assign(options, { humanReadable: false });
Object.assign(options, { humanReadable: false, digits: undefined, minDigits: undefined});
expect(formatFloat(-0.0000001, options)).toBe("0.00");
});
});

View file

@ -11,6 +11,7 @@ test("fuzzyLookup", () => {
{ name: "Jane Yellow" },
{ name: "Brandon Green" },
{ name: "Jérémy Red" },
{ name: "สมศรี จู่โจม" },
];
expect(fuzzyLookup("ba", data, (d) => d.name)).toEqual([
{ name: "Brandon Green" },
@ -25,6 +26,7 @@ test("fuzzyLookup", () => {
{ name: "Jane Yellow" },
]);
expect(fuzzyLookup("", data, (d) => d.name)).toEqual([]);
expect(fuzzyLookup("สมศ", data, (d) => d.name)).toEqual([{ name: "สมศรี จู่โจม" }]);
});
test("fuzzyTest", () => {

View file

@ -628,3 +628,53 @@ test("clone option", async () => {
await contains(".item:first-child").dragAndDrop(".item:nth-child(2)");
expect(".placeholder:not(.item)").toHaveCount(0);
});
test("dragged element is removed from the DOM while being dragged", async () => {
class List extends Component {
static props = ["*"];
static template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="state.items" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
setup() {
this.state = useState({
items: [1, 2, 3],
});
useSortable({
ref: useRef("root"),
elements: ".item",
onDragStart() {
expect.step("start");
},
onDragEnd() {
expect.step("end");
},
onDrop() {
expect.step("drop"); // should not be called
},
});
}
}
const list = await mountWithCleanup(List);
expect(".item:visible").toHaveCount(3);
expect(".o_dragged").toHaveCount(0);
expect.verifySteps([]);
const { drop, moveTo } = await contains(".item:first-child").drag();
expect(".o_dragged").toHaveCount(1);
expect.verifySteps(["start"]);
await moveTo(".item:nth-child(2)");
expect(".o_dragged").toHaveCount(1);
list.state.items = [3, 4];
await animationFrame();
expect(".item:visible").toHaveCount(2);
expect(".o_dragged").toHaveCount(0);
await drop();
expect.verifySteps(["end"]);
});

View file

@ -84,6 +84,7 @@ describe("sprintf", () => {
expect(sprintf("Hello!")).toBe("Hello!");
expect(sprintf("Hello %s!")).toBe("Hello %s!");
expect(sprintf("Hello %(value)s!")).toBe("Hello %(value)s!");
expect(sprintf("Hello %(value)s!", {})).toBe("Hello !");
});
test("properly formats numbers", () => {
@ -108,6 +109,16 @@ describe("sprintf", () => {
const vals = { one: _t("one"), two: _t("two") };
expect(sprintf("Hello %(two)s %(one)s", vals)).toBe("Hello två en");
});
test("supports escaped '%' signs", () => {
expect(sprintf("Escape %s", "%s")).toBe("Escape %s");
expect(sprintf("Escape %%s", "this!")).toBe("Escape %s");
expect(sprintf("Escape %%%s", "this!")).toBe("Escape %this!");
expect(sprintf("Escape %%%%s!", "this")).toBe("Escape %%s!");
expect(sprintf("Escape %s%s", "this!")).toBe("Escape this!");
expect(sprintf("Escape %%s%s", "this!")).toBe("Escape %sthis!");
expect(sprintf("Escape %foo!", "this")).toBe("Escape %foo!");
});
});
test("capitalize", () => {