mail/static/tests/core/record_tests.js
Данил Воробьев 6e6f15d803 initial commit
2024-05-03 09:40:35 +00:00

693 lines
24 KiB
JavaScript

/* @odoo-module */
import { BaseStore, Record, makeStore, modelRegistry } from "@mail/core/common/record";
import { registry } from "@web/core/registry";
import { clearRegistryWithCleanup, makeTestEnv } from "@web/../tests/helpers/mock_env";
import { assertSteps, step } from "@web/../tests/utils";
import { markup, reactive, toRaw } from "@odoo/owl";
const serviceRegistry = registry.category("services");
let start;
QUnit.module("record", {
beforeEach() {
serviceRegistry.add("store", { start: (env) => makeStore(env) });
clearRegistryWithCleanup(modelRegistry);
Record.register();
({ Store: class extends BaseStore {} }).Store.register();
start = async () => {
const env = await makeTestEnv();
return env.services.store;
};
},
});
QUnit.test("Insert by passing only single-id value (non-relational)", async (assert) => {
(class Persona extends Record {
static id = "name";
name;
}).register();
const store = await start();
const john = store.Persona.insert("John");
assert.strictEqual(john.name, "John");
});
QUnit.test("Can pass object as data for relational field with inverse as id", async (assert) => {
(class Thread extends Record {
static id = "name";
name;
composer = Record.one("Composer", { inverse: "thread" });
}).register();
(class Composer extends Record {
static id = "thread";
thread = Record.one("Thread");
}).register();
const store = await start();
const thread = store.Thread.insert("General");
Object.assign(thread, { composer: {} });
assert.ok(thread.composer);
assert.ok(thread.composer.thread.eq(thread));
});
QUnit.test("Assign & Delete on fields with inverses", async (assert) => {
(class Thread extends Record {
static id = "name";
name;
composer = Record.one("Composer", { inverse: "thread" });
members = Record.many("Member", { inverse: "thread" });
messages = Record.many("Message", { inverse: "threads" });
}).register();
(class Composer extends Record {
static id = "thread";
thread = Record.one("Thread");
}).register();
(class Member extends Record {
static id = "name";
name;
thread = Record.one("Thread");
}).register();
(class Message extends Record {
static id = "content";
content;
threads = Record.many("Thread");
}).register();
const store = await start();
const thread = store.Thread.insert("General");
const [john, marc] = store.Member.insert(["John", "Marc"]);
const [hello, world] = store.Message.insert(["hello", "world"]);
// Assign on fields should adapt inverses
Object.assign(thread, { composer: {}, members: [["ADD", john]], messages: [hello, world] });
assert.ok(thread.composer);
assert.ok(thread.composer.thread.eq(thread));
assert.ok(john.thread.eq(thread));
assert.ok(john.in(thread.members));
assert.ok(hello.in(thread.messages));
assert.ok(world.in(thread.messages));
assert.ok(thread.in(hello.threads));
assert.ok(thread.in(world.threads));
// add() should adapt inverses
thread.members.add(marc);
assert.ok(marc.in(thread.members));
assert.ok(marc.thread.eq(thread));
// delete should adapt inverses
thread.members.delete(john);
assert.notOk(john.in(thread.members));
assert.notOk(john.thread);
// can delete with command
thread.messages = [["DELETE", world]];
assert.notOk(world.in(thread.messages));
assert.notOk(thread.in(world.threads));
assert.ok(thread.messages.length === 1);
assert.ok(hello.in(thread.messages));
assert.ok(thread.in(hello.threads));
// Deletion removes all relations
const composer = thread.composer;
thread.delete();
assert.notOk(thread.composer);
assert.notOk(composer.thread);
assert.notOk(marc.in(thread.members));
assert.ok(thread.members.length === 0);
assert.notOk(hello.in(thread.messages));
assert.notOk(thread.in(hello.threads));
assert.ok(thread.messages.length === 0);
});
QUnit.test("onAdd/onDelete hooks on relational with inverse", async (assert) => {
let logs = [];
(class Thread extends Record {
static id = "name";
name;
members = Record.many("Member", {
inverse: "thread",
onAdd: (member) => logs.push(`Thread.onAdd(${member.name})`),
onDelete: (member) => logs.push(`Thread.onDelete(${member.name})`),
});
}).register();
(class Member extends Record {
static id = "name";
name;
thread = Record.one("Thread");
}).register();
const store = await start();
const thread = store.Thread.insert("General");
const [john, marc] = store.Member.insert(["John", "Marc"]);
thread.members.add(john);
assert.deepEqual(logs, ["Thread.onAdd(John)"]);
logs = [];
thread.members.add(john);
assert.deepEqual(logs, []);
marc.thread = thread;
assert.deepEqual(logs, ["Thread.onAdd(Marc)"]);
logs = [];
thread.members.delete(marc);
assert.deepEqual(logs, ["Thread.onDelete(Marc)"]);
logs = [];
thread.members.delete(marc);
assert.deepEqual(logs, []);
john.thread = undefined;
assert.deepEqual(logs, ["Thread.onDelete(John)"]);
});
QUnit.test("Computed fields", async (assert) => {
(class Thread extends Record {
static id = "name";
name;
type = Record.attr("", {
compute() {
if (this.members.length === 0) {
return "empty chat";
} else if (this.members.length === 1) {
return "self-chat";
} else if (this.members.length === 2) {
return "dm chat";
} else {
return "group chat";
}
},
});
admin = Record.one("Persona", {
compute() {
return this.members[0];
},
});
members = Record.many("Persona");
}).register();
(class Persona extends Record {
static id = "name";
name;
}).register();
const store = await start();
const thread = store.Thread.insert("General");
const [john, marc, antony] = store.Persona.insert(["John", "Marc", "Antony"]);
Object.assign(thread, { members: [john, marc] });
assert.ok(thread.admin.eq(john));
assert.strictEqual(thread.type, "dm chat");
thread.members.delete(john);
assert.ok(thread.admin.eq(marc));
assert.strictEqual(thread.type, "self-chat");
thread.members.unshift(antony, john);
assert.ok(thread.admin.eq(antony));
assert.strictEqual(thread.type, "group chat");
});
QUnit.test("Computed fields: lazy (default) vs. eager", async (assert) => {
(class Thread extends Record {
static id = "name";
name;
computeType() {
if (this.members.length === 0) {
return "empty chat";
} else if (this.members.length === 1) {
return "self-chat";
} else if (this.members.length === 2) {
return "dm chat";
} else {
return "group chat";
}
}
typeLazy = Record.attr("", {
compute() {
assert.step("LAZY");
return this.computeType();
},
});
typeEager = Record.attr("", {
compute() {
assert.step("EAGER");
return this.computeType();
},
eager: true,
});
members = Record.many("Persona");
}).register();
(class Persona extends Record {
static id = "name";
name;
}).register();
const store = await start();
const thread = store.Thread.insert("General");
const members = thread.members;
assert.verifySteps(["EAGER"]);
assert.strictEqual(thread.typeEager, "empty chat");
assert.verifySteps([]);
assert.strictEqual(thread.typeLazy, "empty chat");
assert.verifySteps(["LAZY"]);
members.add("John");
assert.verifySteps(["EAGER"]);
assert.strictEqual(thread.typeEager, "self-chat");
assert.verifySteps([]);
members.add("Antony");
assert.verifySteps(["EAGER"]);
assert.strictEqual(thread.typeEager, "dm chat");
assert.verifySteps([]);
members.add("Demo");
assert.verifySteps(["EAGER"]);
assert.strictEqual(thread.typeEager, "group chat");
assert.strictEqual(thread.typeLazy, "group chat");
assert.verifySteps(["LAZY"]);
});
QUnit.test("Trusted insert on html field with { html: true }", async (assert) => {
(class Message extends Record {
static id = "body";
body = Record.attr("", { html: true });
}).register();
const store = await start();
const hello = store.Message.insert("<p>hello</p>", { html: true });
const world = store.Message.insert("<p>world</p>");
assert.ok(hello.body instanceof markup("").constructor);
assert.strictEqual(hello.body.toString(), "<p>hello</p>");
assert.strictEqual(world.body, "<p>world</p>");
});
QUnit.test("(Un)trusted insertion is applied even with same field value", async (assert) => {
(class Message extends Record {
static id = "id";
id;
body = Record.attr("", { html: true });
}).register();
const store = await start();
const rawMessage = { id: 1, body: "<p>hello</p>" };
let message = store.Message.insert(rawMessage);
assert.notOk(message.body instanceof markup("").constructor);
message = store.Message.insert(rawMessage, { html: true });
assert.ok(message.body instanceof markup("").constructor);
message = store.Message.insert(rawMessage);
assert.notOk(message.body instanceof markup("").constructor);
});
QUnit.test("Unshift preserves order", async (assert) => {
(class Message extends Record {
static id = "id";
id;
}).register();
(class Thread extends Record {
static id = "name";
name;
messages = Record.many("Message");
}).register();
const store = await start();
const thread = store.Thread.insert({ name: "General" });
thread.messages.unshift({ id: 3 }, { id: 2 }, { id: 1 });
assert.deepEqual(
thread.messages.map((msg) => msg.id),
[3, 2, 1]
);
thread.messages.unshift({ id: 6 }, { id: 5 }, { id: 4 });
assert.deepEqual(
thread.messages.map((msg) => msg.id),
[6, 5, 4, 3, 2, 1]
);
thread.messages.unshift({ id: 7 });
assert.deepEqual(
thread.messages.map((msg) => msg.id),
[7, 6, 5, 4, 3, 2, 1]
);
});
QUnit.test("onAdd hook should see fully inserted data", async (assert) => {
(class Thread extends Record {
static id = "name";
name;
members = Record.many("Member", {
inverse: "thread",
onAdd: (member) =>
assert.step(`Thread.onAdd::${member.name}.${member.type}.${member.isAdmin}`),
});
}).register();
(class Member extends Record {
static id = "name";
name;
type;
isAdmin = Record.attr(false, {
compute() {
return this.type === "admin";
},
});
thread = Record.one("Thread");
}).register();
const store = await start();
const thread = store.Thread.insert("General");
thread.members.add({ name: "John", type: "admin" });
assert.verifySteps(["Thread.onAdd::John.admin.true"]);
});
QUnit.test("Can insert with relation as id, using relation as data object", async (assert) => {
(class User extends Record {
static id = "name";
name;
settings = Record.one("Settings");
}).register();
(class Settings extends Record {
static id = "user";
pushNotif;
user = Record.one("User", { inverse: "settings" });
}).register();
const store = await start();
store.Settings.insert([
{ pushNotif: true, user: { name: "John" } },
{ pushNotif: false, user: { name: "Paul" } },
]);
assert.ok(store.User.get("John"));
assert.ok(store.User.get("John").settings.pushNotif);
assert.ok(store.User.get("Paul"));
assert.notOk(store.User.get("Paul").settings.pushNotif);
});
QUnit.test("Set on attr should invoke onChange", async (assert) => {
(class Message extends Record {
static id = "id";
id;
body;
}).register();
const store = await start();
const message = store.Message.insert(1);
Record.onChange(message, "body", () => assert.step("BODY_CHANGED"));
assert.verifySteps([]);
message.update({ body: "test1" });
message.body = "test2";
assert.verifySteps(["BODY_CHANGED", "BODY_CHANGED"]);
});
QUnit.test("record list sort should be manually observable", async (assert) => {
(class Thread extends Record {
static id = "id";
id;
messages = Record.many("Message", { inverse: "thread" });
}).register();
(class Message extends Record {
static id = "id";
id;
body;
author;
thread = Record.one("Thread", { inverse: "messages" });
}).register();
const store = await start();
const thread = store.Thread.insert(1);
const messages = store.Message.insert([
{ id: 1, body: "a", thread },
{ id: 2, body: "b", thread },
]);
function sortMessages() {
// minimal access through observed variables to reduce unexpected observing
observedMessages.sort((m1, m2) => (m1.body < m2.body ? -1 : 1));
assert.step(`sortMessages`);
}
const observedMessages = reactive(thread.messages, sortMessages);
assert.equal(`${thread.messages.map((m) => m.id)}`, "1,2");
sortMessages();
assert.equal(`${thread.messages.map((m) => m.id)}`, "1,2");
assert.verifySteps(["sortMessages"]);
messages[0].body = "c";
assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
assert.verifySteps(["sortMessages", "sortMessages"]);
messages[0].body = "d";
assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
assert.verifySteps(["sortMessages"]);
messages[0].author = "Jane";
assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
assert.verifySteps([]);
store.Message.insert({ id: 3, body: "c", thread });
assert.equal(`${thread.messages.map((m) => m.id)}`, "2,3,1");
assert.verifySteps(["sortMessages", "sortMessages"]);
messages[0].delete();
assert.equal(`${thread.messages.map((m) => m.id)}`, "2,3");
assert.verifySteps(["sortMessages"]);
});
QUnit.test("relation field sort should be automatically observed", async (assert) => {
(class Thread extends Record {
static id = "id";
id;
messages = Record.many("Message", {
inverse: "thread",
sort: (m1, m2) => (m1.body < m2.body ? -1 : 1),
});
}).register();
(class Message extends Record {
static id = "id";
id;
body;
author;
thread = Record.one("Thread", { inverse: "messages" });
}).register();
const store = await start();
const thread = store.Thread.insert(1);
const messages = store.Message.insert([
{ id: 1, body: "a", thread },
{ id: 2, body: "b", thread },
]);
assert.equal(`${thread.messages.map((m) => m.id)}`, "1,2");
messages[0].body = "c";
assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
messages[0].body = "d";
assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
messages[0].author = "Jane";
assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
store.Message.insert({ id: 3, body: "c", thread });
assert.equal(`${thread.messages.map((m) => m.id)}`, "2,3,1");
messages[0].delete();
assert.equal(`${thread.messages.map((m) => m.id)}`, "2,3");
});
QUnit.test("reading of lazy compute relation field should recompute", async (assert) => {
(class Thread extends Record {
static id = "id";
id;
messages = Record.many("Message", {
inverse: "thread",
sort: (m1, m2) => (m1.body < m2.body ? -1 : 1),
});
messages2 = Record.many("Message", {
compute() {
return this.messages.map((m) => m.id);
},
});
}).register();
(class Message extends Record {
static id = "id";
id;
thread = Record.one("Thread", { inverse: "messages" });
}).register();
const store = await start();
const thread = store.Thread.insert(1);
store.Message.insert([
{ id: 1, thread },
{ id: 2, thread },
]);
const messages2 = thread.messages2;
assert.strictEqual(`${messages2.map((m) => m.id)}`, "1,2");
store.Message.insert([{ id: 3, thread }]);
assert.strictEqual(`${messages2.map((m) => m.id)}`, "1,2,3");
store.Message.insert([{ id: 4, thread }]);
assert.strictEqual(`${messages2.map((m) => m.id)}`, "1,2,3,4");
});
QUnit.test("lazy compute should re-compute while they are observed", async (assert) => {
(class Channel extends Record {
static id = "id";
id;
count = 0;
multiplicity = Record.attr(undefined, {
compute() {
assert.step("computing");
if (this.count > 3) {
return "many";
}
return "few";
},
});
}).register();
const store = await start();
const channel = store.Channel.insert(1);
let observe = true;
function render() {
if (observe) {
assert.step(`render ${reactiveChannel.multiplicity}`);
}
}
const reactiveChannel = reactive(channel, render);
render();
assert.verifySteps(
["computing", "render few", "render few"],
"initial call, render with new value"
);
channel.count = 2;
assert.verifySteps(["computing"], "changing value to 2 is observed");
channel.count = 5;
assert.verifySteps(["computing", "render many"], "changing value to 5 is observed");
observe = false;
channel.count = 6;
assert.verifySteps(["computing"], "changing value to 6, still observed until it changes");
channel.count = 7;
assert.verifySteps(["computing"], "changing value to 7, still observed until it changes");
channel.count = 1;
assert.verifySteps(["computing"], "changing value to 1, observed one last time");
channel.count = 0;
assert.verifySteps([], "changing value to 0, no longer observed");
channel.count = 7;
assert.verifySteps([], "changing value to 7, no longer observed");
channel.count = 1;
assert.verifySteps([], "changing value to 1, no longer observed");
assert.strictEqual(channel.multiplicity, "few");
assert.verifySteps(["computing"]);
observe = true;
render();
assert.verifySteps(["render few"]);
channel.count = 7;
assert.verifySteps(["computing", "render many"]);
});
QUnit.test("lazy sort should re-sort while they are observed", async (assert) => {
(class Thread extends Record {
static id = "id";
id;
messages = Record.many("Message", {
sort: (m1, m2) => m1.sequence - m2.sequence,
});
}).register();
(class Message extends Record {
static id = "id";
id;
sequence;
}).register();
const store = await start();
const thread = store.Thread.insert(1);
thread.messages.push({ id: 1, sequence: 1 }, { id: 2, sequence: 2 });
assert.equal(`${thread.messages.map((m) => m.id)}`, "1,2");
let observe = true;
function render() {
if (observe) {
assert.step(`render ${reactiveChannel.messages.map((m) => m.id)}`);
}
}
const reactiveChannel = reactive(thread, render);
render();
const message = thread.messages[0];
assert.verifySteps(["render 1,2"]);
message.sequence = 3;
assert.verifySteps(["render 2,1"]);
message.sequence = 4;
assert.verifySteps([]);
message.sequence = 5;
assert.verifySteps([]);
message.sequence = 1;
assert.verifySteps(["render 1,2"]);
observe = false;
message.sequence = 10;
assert.equal(
`${toRaw(thread)
._raw._fields.get("messages")
.value.data.map((localId) => toRaw(thread)._raw._store.get(localId).id)}`,
"2,1",
"observed one last time when it changes"
);
assert.verifySteps([]);
message.sequence = 1;
assert.equal(
`${toRaw(thread)
._raw._fields.get("messages")
.value.data.map((localId) => toRaw(thread)._raw._store.get(localId).id)}`,
"2,1",
"no longer observed"
);
assert.equal(`${thread.messages.map((m) => m.id)}`, "1,2");
observe = true;
render();
assert.verifySteps(["render 1,2"]);
message.sequence = 10;
assert.verifySteps(["render 2,1"]);
});
QUnit.test("store updates can be observed", async (assert) => {
const store = await start();
function onUpdate() {
assert.step(`abc:${reactiveStore.abc}`);
}
const rawStore = toRaw(store)._raw;
const reactiveStore = reactive(store, onUpdate);
onUpdate();
assert.verifySteps(["abc:undefined"]);
store.abc = 1;
assert.verifySteps(["abc:1"], "observable from makeStore");
rawStore._store.abc = 2;
assert.verifySteps(["abc:2"], "observable from record._store");
rawStore.Model.store.abc = 3;
assert.verifySteps(["abc:3"], "observable from Model.store");
});
QUnit.test("onAdd/onDelete hooks on one without inverse", async () => {
(class Thread extends Record {
static id = "name";
}).register();
(class Member extends Record {
static id = "name";
name;
thread = Record.one("Thread", {
onAdd: (thread) => step(`thread.onAdd(${thread.name})`),
onDelete: (thread) => step(`thread.onDelete(${thread.name})`),
});
}).register();
const store = await start();
const general = store.Thread.insert("General");
const john = store.Member.insert("John");
await assertSteps([]);
john.thread = general;
await assertSteps(["thread.onAdd(General)"]);
john.thread = general;
await assertSteps([]);
john.thread = undefined;
await assertSteps(["thread.onDelete(General)"]);
});
QUnit.test("onAdd/onDelete hooks on many without inverse", async () => {
(class Thread extends Record {
static id = "name";
name;
members = Record.many("Member", {
onAdd: (member) => step(`members.onAdd(${member.name})`),
onDelete: (member) => step(`members.onDelete(${member.name})`),
});
}).register();
(class Member extends Record {
static id = "name";
}).register();
const store = await start();
const general = store.Thread.insert("General");
const jane = store.Member.insert("Jane");
const john = store.Member.insert("John");
await assertSteps([]);
general.members = jane;
await assertSteps(["members.onAdd(Jane)"]);
general.members = jane;
await assertSteps([]);
general.members = [["ADD", john]];
await assertSteps(["members.onAdd(John)"]);
general.members = undefined;
await assertSteps(["members.onDelete(John)", "members.onDelete(Jane)"]);
});
QUnit.test("record list assign should update inverse fields", async (assert) => {
(class Thread extends Record {
static id = "name";
name;
members = Record.many("Member", { inverse: "thread" });
}).register();
(class Member extends Record {
static id = "name";
thread = Record.one("Thread", { inverse: "members" });
}).register();
const store = await start();
const general = store.Thread.insert("General");
const jane = store.Member.insert("Jane");
general.members = jane; // direct assignation of value goes through assign()
assert.ok(jane.thread.eq(general));
general.members = []; // writing empty array specifically goes through assign()
assert.notOk(jane.thread);
jane.thread = general;
assert.ok(jane.in(general.members));
jane.thread = [];
assert.ok(jane.notIn(general.members));
});