@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
1,484 lines (1,301 loc) • 67 kB
JavaScript
"use strict";
import * as chai from "chai";
import { addToObjectLink } from "../../../source/dom/attributes.mjs";
import { customElementUpdaterLinkSymbol } from "../../../source/dom/constants.mjs";
import { ID } from "../../../source/types/id.mjs";
import { Observer } from "../../../source/types/observer.mjs";
import { ProxyObserver } from "../../../source/types/proxyobserver.mjs";
import { chaiDom } from "../../util/chai-dom.mjs";
import { initJSDOM } from "../../util/jsdom.mjs";
let expect = chai.expect;
chai.use(chaiDom);
let html1 = `
<template id="current">
<li data-monster-replace="path:current | tojson"></li>
</template>
<div id="test1">
<ul data-monster-insert="current path:a.b">
</ul>
</div>
<div id="test2">
<ul data-monster-insert="current path:a.b | doit">
</ul>
</div>
<div id="test3">
<div data-monster-attributes="class path:a.b">
<input data-monster-attributes="value path:a.c" id="input1">
<input data-monster-attributes="checked path:a.checkbox" type="checkbox" name="checkbox" id="checkbox">
<input data-monster-attributes="value path:a.text" type="text" name="text" id="text">
<input data-monster-attributes="checked path:a.radio" type="radio" name="radio" value="r1" id="radio">
<input type="radio" name="radio" value="r2" id="r2">
<input type="radio" name="radio" value="rx" id="rx">
<select data-monster-attributes="value path:a.select" name="select" id="select">
<option value="other-value">value1</option>
<option>value2</option>
</select>
<select data-monster-attributes="value path:a.multiselect" name="multiselect" multiple id="multiselect">
<option>value1</option>
<option>value2</option>
<option>value3</option>
<option>value4</option>
<option value="other-value5">value5</option>
</select>
<textarea name="textarea" id="textarea" data-monster-attributes="value path:a.textarea"></textarea>
<input id="property-checkbox" type="checkbox" data-monster-properties="checked path:a.checkboxBool">
<input id="property-input" type="text" data-monster-properties="value path:a.checkboxBool">
<monster-test-property id="property-custom" data-monster-properties="active path:a.checkboxBool, payload path:a.payload"></monster-test-property>
<monster-test-option-control id="property-option-control" data-monster-properties="option:features.enabled path:a.checkboxBool, option:labels.count path:a.optionCount"></monster-test-option-control>
</div>
</div>
`;
let html2 = `
<div id="test1">
<div data-monster-replace="path:text | tolower"></div>
<div data-monster-replace="path:text | call:myformatter"></div>
<div data-monster-replace="static:hello\\ "></div>
</div>
`;
let html3 = `
<template id="myinnerid">
<span data-monster-replace="path:myinnerid | tojson" data-monster-properties="title path:myinnerid.i"></span>
</template>
<template id="myid">
<p data-monster-insert="myinnerid path:a.b"></p>
</template>
<div id="test1">
<div data-monster-insert="myid path:a.b"></div>
</div>
`;
let html4 = `
<div>
<form id="form1">
<input type="checkbox" value="checked" name="checkbox" data-monster-bind="path:state">
<input type="text" name="text">
<input type="radio" name="radio" value="r1" id="r1" data-monster-bind="path:radio">
<input type="radio" name="radio" value="r2" id="r2" data-monster-bind="path:radio">
<input type="radio" name="radio" value="rx" id="rx" data-comment="not called because no bind attribute">
<input type="button" name="button">
<select name="select1" id="select1" data-monster-bind="path:select">
<option>value1</option>
<option>value2</option>
</select>
<select name="select2" multiple id="select2" data-monster-bind="path:multiselect">
<option>value1</option>
<option>value2</option>
<option>value3</option>
<option>value4</option>
<option>value5</option>
</select>
<textarea name="textarea" id="textarea" data-monster-bind="path:textarea">
</textarea>
</form>
</div>
`;
let htmlNested = `
<div id="parent">
<input id="parent-input" type="text" data-monster-bind="path:parentVal">
<div id="child">
<input id="child-input" type="text" data-monster-bind="path:childVal">
</div>
</div>
`;
let htmlStatefulReplace = `
<div id="test-stateful">
<div data-monster-replace="path:content"></div>
</div>
`;
let htmlPatch = `
<template id="patch-item">
<li data-monster-patch="path:patch-item.label"></li>
</template>
<div id="test-patch">
<div id="patch-text" data-monster-patch="path:text"></div>
<div id="patch-node" data-monster-patch="path:contentNode"></div>
<div id="patch-fragment" data-monster-patch="path:fragmentNode"></div>
<div id="patch-array" data-monster-patch="path:itemsArray"></div>
<div id="patch-keyed" data-monster-patch="path:keyedItems" data-monster-patch-key="call:getPatchKey"></div>
<div id="patch-keyed-render" data-monster-patch="path:keyedDataItems" data-monster-patch-key="path:id" data-monster-patch-render="path:label"></div>
<ul id="patch-list" data-monster-insert="patch-item path:items"></ul>
</div>
`;
let htmlPatchVsReplace = `
<div id="test-patch-vs-replace">
<div id="replace-target" data-monster-replace="path:replaceHtml"></div>
<div
id="patch-target"
data-monster-patch="path:patchItems"
data-monster-patch-key="path:id"
data-monster-patch-render="call:renderPatchItem"
></div>
<div
id="replace-race-target"
data-monster-replace="path:replaceRaceHtml"
></div>
<div
id="patch-race-target"
data-monster-patch="path:patchRaceItems"
data-monster-patch-key="path:id"
data-monster-patch-render="call:renderPatchRaceItem"
></div>
</div>
`;
describe("DOM", function () {
let Updater = null;
before(function (done) {
const options = {};
initJSDOM(options).then(() => {
import("../../../source/dom/updater.mjs")
.then((m) => {
Updater = m.Updater;
if (!customElements.get("monster-test-property")) {
class MonsterTestProperty extends HTMLElement {
set active(value) {
this._active = value;
}
get active() {
return this._active;
}
set payload(value) {
this._payload = value;
}
get payload() {
return this._payload;
}
}
customElements.define("monster-test-property", MonsterTestProperty);
}
if (!customElements.get("monster-test-option-control")) {
class MonsterTestOptionControl extends HTMLElement {
constructor() {
super();
this._options = {
features: {
enabled: false,
},
labels: {
count: 0,
},
};
}
getOption(path) {
return path.split(".").reduce((ref, part) => ref?.[part], this._options);
}
setOption(path, value) {
const parts = path.split(".");
let ref = this._options;
while (parts.length > 1) {
const part = parts.shift();
if (ref[part] === undefined || ref[part] === null) {
ref[part] = {};
}
ref = ref[part];
}
ref[parts[0]] = value;
}
}
customElements.define(
"monster-test-option-control",
MonsterTestOptionControl,
);
}
if (!customElements.get("monster-test-churn-item")) {
class MonsterTestChurnItem extends HTMLElement {
static stats = {
replace: { created: 0, connected: 0, disconnected: 0 },
patch: { created: 0, connected: 0, disconnected: 0 },
};
connectedCallback() {
const mode = this.getAttribute("data-mode") || "replace";
if (this.__countedCreated !== true) {
this.__countedCreated = true;
MonsterTestChurnItem.stats[mode].created++;
}
MonsterTestChurnItem.stats[mode].connected++;
}
disconnectedCallback() {
const mode = this.getAttribute("data-mode") || "replace";
MonsterTestChurnItem.stats[mode].disconnected++;
}
static resetStats() {
MonsterTestChurnItem.stats = {
replace: { created: 0, connected: 0, disconnected: 0 },
patch: { created: 0, connected: 0, disconnected: 0 },
};
}
}
customElements.define(
"monster-test-churn-item",
MonsterTestChurnItem,
);
}
if (!customElements.get("monster-test-race-item")) {
class MonsterTestRaceItem extends HTMLElement {
static stats = {
replace: {
connected: 0,
disconnected: 0,
firedConnected: 0,
firedDisconnected: 0,
},
patch: {
connected: 0,
disconnected: 0,
firedConnected: 0,
firedDisconnected: 0,
},
};
connectedCallback() {
const mode = this.getAttribute("data-mode") || "replace";
MonsterTestRaceItem.stats[mode].connected++;
setTimeout(() => {
if (this.isConnected) {
MonsterTestRaceItem.stats[mode].firedConnected++;
} else {
MonsterTestRaceItem.stats[mode].firedDisconnected++;
}
}, 10);
}
disconnectedCallback() {
const mode = this.getAttribute("data-mode") || "replace";
MonsterTestRaceItem.stats[mode].disconnected++;
}
static resetStats() {
MonsterTestRaceItem.stats = {
replace: {
connected: 0,
disconnected: 0,
firedConnected: 0,
firedDisconnected: 0,
},
patch: {
connected: 0,
disconnected: 0,
firedConnected: 0,
firedDisconnected: 0,
},
};
}
}
customElements.define(
"monster-test-race-item",
MonsterTestRaceItem,
);
}
done();
})
.catch((e) => {
done(e);
});
});
});
beforeEach(() => {
let mocks = document.getElementById("mocks");
mocks.innerHTML = html1;
});
afterEach(() => {
let mocks = document.getElementById("mocks");
mocks.innerHTML = "";
});
// REFACTOR: Ein einziger, klar benannter describe-Block für zusammengehörige Tests.
describe("Setup, Configuration, and Error Handling", function () {
let element;
// REFACTOR: Code-Wiederholung durch einen beforeEach-Hook vermeiden.
beforeEach(() => {
// Annahme: Die HTML-Struktur aus der vorherigen Anfrage ist im DOM vorhanden.
// Falls nicht, müsste hier das entsprechende HTML geladen werden.
element = document.getElementById("test1");
});
describe("Configuration Methods", function () {
it("setEventTypes() should be chainable", function () {
const u = new Updater(element);
expect(u.setEventTypes(["touch"])).to.be.instanceof(Updater);
});
it("getSubject() should return the subject proxy", function () {
const subject = { a: 1 };
const u = new Updater(element, subject);
expect(u.getSubject().a).to.be.equal(1);
});
it("enableEventProcessing() should be chainable", function () {
const u = new Updater(element);
expect(u.enableEventProcessing()).to.be.instanceof(Updater);
});
it("disableEventProcessing() should be chainable", function () {
const u = new Updater(element);
expect(u.disableEventProcessing()).to.be.instanceof(Updater);
});
it("dispose() should stop future DOM updates", function (done) {
const subject = { text: "first" };
const target = document.createElement("div");
target.innerHTML = `<div data-monster-replace="path:text"></div>`;
document.getElementById("mocks").appendChild(target);
const u = new Updater(target, subject);
u.run()
.then(() => {
setTimeout(() => {
try {
expect(target).contain.html("<div data-monster-replace=\"path:text\">first</div>");
u.dispose();
u.getSubject().text = "second";
setTimeout(() => {
try {
expect(target).contain.html("<div data-monster-replace=\"path:text\">first</div>");
done();
} catch (e) {
done(e);
}
}, 20);
} catch (e) {
done(e);
}
}, 0);
})
.catch((e) => done(e));
});
});
describe("Constructor Error Handling", function () {
it("should throw a TypeError if no HTMLElement is provided", function () {
expect(() => new Updater()).to.throw(TypeError);
});
it("should throw a TypeError if the subject is not an object", function () {
expect(() => new Updater(element, null)).to.throw(TypeError);
});
});
describe("Setup, Configuration, and Error Handling", function () {
let element;
beforeEach(() => {
element = document.getElementById("test1");
});
describe("Configuration Methods", function () {
it("setEventTypes() should be chainable", function () {
const u = new Updater(element);
expect(u.setEventTypes(["touch"])).to.be.instanceof(Updater);
});
it("getSubject() should return the subject proxy", function () {
const subject = { a: 1 };
const u = new Updater(element, subject);
expect(u.getSubject().a).to.be.equal(1);
});
it("enableEventProcessing() should be chainable", function () {
const u = new Updater(element);
expect(u.enableEventProcessing()).to.be.instanceof(Updater);
});
it("disableEventProcessing() should be chainable", function () {
const u = new Updater(element);
expect(u.disableEventProcessing()).to.be.instanceof(Updater);
});
});
describe("Constructor Error Handling", function () {
it("should throw a TypeError if no HTMLElement is provided", function () {
expect(() => new Updater()).to.throw(TypeError);
});
it("should throw a TypeError if the subject is not an object", function () {
expect(() => new Updater(element, null)).to.throw(TypeError);
});
});
describe("Runtime Error Handling", function () {
it("should add an error attribute if a value for data-monster-insert is not iterable", function (done) {
const element = document.getElementById("test1");
const u = new Updater(element, { a: { b: null } });
u.run()
.then(() => {
const expectedErrorMessage = "the value is not iterable";
expect(element).to.have.attribute(
"data-monster-error",
expectedErrorMessage,
);
done();
})
.catch((err) => {
done(
new Error(
"Promise was rejected but should have been resolved. Error: " +
err,
),
);
});
});
});
});
});
describe("Updater()", function () {
describe("new Updater", function () {
it("should return document object", function () {
let element = document.getElementById("test1");
let d = new Updater(element, {});
expect(typeof d).is.equal("object");
});
});
});
describe("Updater()", function () {
beforeEach(() => {
let mocks = document.getElementById("mocks");
mocks.innerHTML = htmlPatchVsReplace;
customElements.get("monster-test-churn-item").resetStats();
customElements.get("monster-test-race-item").resetStats();
});
describe("Patch versus Replace", function () {
it("should show less lifecycle churn for keyed patch than for replace under repeated updates", function (done) {
const buildReplaceHtml = (items) =>
items
.map(
({ id, label }) =>
`<monster-test-churn-item data-mode="replace" data-id="${id}">${label}</monster-test-churn-item>`,
)
.join("");
const patchNodes = new Map();
const renderPatchItem = (item) => {
let node = patchNodes.get(item.id);
if (!(node instanceof HTMLElement)) {
node = document.createElement("monster-test-churn-item");
node.setAttribute("data-mode", "patch");
node.setAttribute("data-id", item.id);
patchNodes.set(item.id, node);
}
node.textContent = item.label;
return node;
};
const createItems = (suffix = "") => [
{ id: "a", label: `Alpha${suffix}` },
{ id: "b", label: `Beta${suffix}` },
{ id: "c", label: `Gamma${suffix}` },
{ id: "d", label: `Delta${suffix}` },
];
const subject = {
replaceHtml: buildReplaceHtml(createItems()),
patchItems: createItems(),
};
const updater = new Updater(
document.getElementById("test-patch-vs-replace"),
subject,
);
updater.setCallback("renderPatchItem", renderPatchItem);
updater
.run()
.then(() => {
setTimeout(() => {
try {
const sequences = [
["d", "a", "b", "c"],
["c", "d", "a", "b"],
["b", "c", "d", "a"],
["a", "b", "c", "d"],
];
for (let i = 0; i < 6; i++) {
const next = sequences[i % sequences.length].map((id) => {
const labelMap = {
a: "Alpha",
b: "Beta",
c: "Gamma",
d: "Delta",
};
return {
id,
label: `${labelMap[id]}-${i}`,
};
});
updater.getSubject().replaceHtml = buildReplaceHtml(next);
updater.getSubject().patchItems = next;
}
setTimeout(() => {
try {
const churnStats =
customElements.get("monster-test-churn-item").stats;
expect(churnStats.patch.created).to.equal(4);
expect(churnStats.replace.created).to.be.greaterThan(
churnStats.patch.created,
);
expect(churnStats.replace.disconnected).to.be.greaterThan(0);
expect(churnStats.replace.created).to.be.greaterThan(
churnStats.replace.disconnected / 2,
);
done();
} catch (e) {
done(e);
}
}, 40);
} catch (e) {
done(e);
}
}, 20);
})
.catch((e) => done(new Error(e)));
});
it("should reduce disconnected async callback surface with patch compared to replace", function (done) {
const buildReplaceRaceHtml = (items) =>
items
.map(
({ id, label }) =>
`<monster-test-race-item data-mode="replace" data-id="${id}">${label}</monster-test-race-item>`,
)
.join("");
const patchNodes = new Map();
const renderPatchRaceItem = (item) => {
let node = patchNodes.get(item.id);
if (!(node instanceof HTMLElement)) {
node = document.createElement("monster-test-race-item");
node.setAttribute("data-mode", "patch");
node.setAttribute("data-id", item.id);
patchNodes.set(item.id, node);
}
node.textContent = item.label;
return node;
};
const sequences = [
[
{ id: "a", label: "Alpha-0" },
{ id: "b", label: "Beta-0" },
{ id: "c", label: "Gamma-0" },
],
[
{ id: "c", label: "Gamma-1" },
{ id: "a", label: "Alpha-1" },
{ id: "b", label: "Beta-1" },
],
[
{ id: "b", label: "Beta-2" },
{ id: "c", label: "Gamma-2" },
{ id: "a", label: "Alpha-2" },
],
];
const updater = new Updater(
document.getElementById("test-patch-vs-replace"),
{
replaceRaceHtml: buildReplaceRaceHtml(sequences[0]),
patchRaceItems: sequences[0],
},
);
updater.setCallback("renderPatchRaceItem", renderPatchRaceItem);
updater
.run()
.then(() => {
setTimeout(() => {
try {
updater.getSubject().replaceRaceHtml = buildReplaceRaceHtml(
sequences[1],
);
updater.getSubject().patchRaceItems = sequences[1];
updater.getSubject().replaceRaceHtml = buildReplaceRaceHtml(
sequences[2],
);
updater.getSubject().patchRaceItems = sequences[2];
setTimeout(() => {
try {
const raceStats =
customElements.get("monster-test-race-item").stats;
expect(raceStats.patch.firedDisconnected).to.equal(0);
expect(raceStats.replace.firedDisconnected).to.be.greaterThan(
0,
);
expect(raceStats.replace.firedDisconnected).to.be.greaterThan(
raceStats.patch.firedDisconnected,
);
done();
} catch (e) {
done(e);
}
}, 50);
} catch (e) {
done(e);
}
}, 0);
})
.catch((e) => done(new Error(e)));
});
});
});
describe("Updater()", function () {
describe("Repeat", function () {
it("should build 6 li elements from an array", function (done) {
let element = document.getElementById("test1");
const testData = {
a: {
b: [
{ i: "0" },
{ i: "1" },
{ i: "2" },
{ i: "3" },
{ i: "4" },
{ i: "5" },
],
},
};
let d = new Updater(element, testData);
d.run()
.then(() => {
const ul = element.querySelector("ul");
const listItems = ul.children;
// 1. Prüfe die korrekte Anzahl der Elemente
expect(listItems.length).to.equal(6);
// 2. Stichprobenartige Prüfung von Elementen
const firstItem = listItems[0];
expect(firstItem.tagName).to.equal("LI");
expect(firstItem).to.have.attribute(
"data-monster-insert-reference",
"current-0",
);
expect(firstItem.textContent).to.equal('{"i":"0"}');
const lastItem = listItems[5];
expect(lastItem).to.have.attribute(
"data-monster-insert-reference",
"current-5",
);
expect(lastItem.textContent).to.equal('{"i":"5"}');
done();
})
.catch(done);
});
});
});
describe("Updater()", function () {
beforeEach(() => {
let mocks = document.getElementById("mocks");
mocks.innerHTML = html4;
});
describe("Eventhandling", function () {
let updater, form1, proxyobserver;
beforeEach(() => {
proxyobserver = new ProxyObserver({});
updater = new Updater(document.getElementById("form1"), proxyobserver);
form1 = document.getElementById("form1");
});
// here click events are thrown on the checkbox and the setting of the value is observed via the proxyobserver.
it("should handle checkbox click events", function (done) {
updater.enableEventProcessing();
let subject = updater.getSubject();
expect(subject).is.equal(proxyobserver.getSubject());
let expected = ["checked", undefined, "checked"];
// here the notation with function is important, because the pointer is set.
proxyobserver.attachObserver(
new Observer(function () {
let e = expected.shift();
console.log(e, this.getSubject());
if (e === undefined && expected.length === 0)
done(new Error("to many calls"));
if (this.getSubject()["state"] !== e)
done(new Error(this.getSubject()["state"] + " should " + e));
if (expected.length === 0) {
done();
} else {
setTimeout(() => {
form1.querySelector("[name=checkbox]").click();
}, 10);
}
}),
);
setTimeout(() => {
form1.querySelector("[name=checkbox]").click();
}, 10);
});
it("should handle radio click events 1", function (done) {
updater.enableEventProcessing();
let subject = updater.getSubject();
expect(subject).is.equal(proxyobserver.getSubject());
let expected = ["r1", "r2", "r1"];
let clickTargets = ["r2", "r1"];
// here the notation with function is important, because the this pointer is set.
proxyobserver.attachObserver(
new Observer(function () {
let e = expected.shift();
if (e === undefined && expected.length === 0)
done(new Error("to many calls"));
let v = this.getSubject()["radio"];
if (v !== e) done(new Error(v + " should " + e));
if (expected.length === 0) {
done();
} else {
setTimeout(() => {
document.getElementById(clickTargets.shift()).click();
}, 10);
}
}),
);
setTimeout(() => {
document.getElementById("r1").click();
}, 10);
// no handler // bind
setTimeout(() => {
document.getElementById("rx").click();
}, 20);
});
it("should handle select click events 2", function (done) {
let selectElement = document.getElementById("select1");
updater.enableEventProcessing();
let subject = updater.getSubject();
expect(subject).is.equal(proxyobserver.getSubject());
let expected = ["value2", "value1", "value2"];
// here the notation with function is important, because the this pointer is set.
proxyobserver.attachObserver(
new Observer(function () {
let e = expected.shift();
if (e === undefined && expected.length === 0)
done(new Error("to many calls"));
let v = this.getSubject()["select"];
if (v !== e) done(new Error(v + " should " + e));
if (expected.length === 0) {
done();
} else {
setTimeout(() => {
selectElement.selectedIndex =
selectElement.selectedIndex === 1 ? 0 : 1;
selectElement.click();
}, 10);
}
}),
);
setTimeout(() => {
// set value and simulate click event for bubble
selectElement.selectedIndex = 1;
selectElement.click();
}, 20);
});
it("should handle textarea events", function (done) {
let textareaElement = document.getElementById("textarea");
updater.enableEventProcessing();
let subject = updater.getSubject();
expect(subject).is.equal(proxyobserver.getSubject());
let expected = ["testX", "lorem ipsum", ""];
let testValues = ["lorem ipsum", ""];
// here the notation with function is important, because the this pointer is set.
proxyobserver.attachObserver(
new Observer(function () {
let e = expected.shift();
if (e === undefined && expected.length === 0)
done(new Error("to many calls"));
let v = this.getSubject()["textarea"];
if (JSON.stringify(v) !== JSON.stringify(e))
done(
new Error(JSON.stringify(v) + " should " + JSON.stringify(e)),
);
if (expected.length === 0) {
done();
} else {
setTimeout(() => {
textareaElement.value = testValues.shift();
textareaElement.click();
}, 10);
}
}),
);
setTimeout(() => {
// set value and simulate click event for bubble
textareaElement.value = "testX";
textareaElement.click();
}, 20);
});
it("should handle multiple select events", function (done) {
let selectElement = document.getElementById("select2");
updater.enableEventProcessing();
let subject = updater.getSubject();
expect(subject).is.equal(proxyobserver.getSubject());
let expected = [
["value1"],
["value2", "value3", "value4"],
["value1", "value4"],
];
let testSelections = [
[false, true, true, true],
[true, false, false, true],
];
// here the notation with function is important, because the this pointer is set.
proxyobserver.attachObserver(
new Observer(function () {
let e = expected.shift();
if (e === undefined && expected.length === 0)
done(new Error("to many calls"));
let v = this.getSubject()["multiselect"];
if (JSON.stringify(v) !== JSON.stringify(e))
done(
new Error(JSON.stringify(v) + " should " + JSON.stringify(e)),
);
if (expected.length === 0) {
done();
} else {
setTimeout(() => {
let v = testSelections.shift();
selectElement.options[0].selected = v[0];
selectElement.options[1].selected = v[1];
selectElement.options[2].selected = v[2];
selectElement.options[3].selected = v[3];
selectElement.click();
}, 10);
}
}),
);
setTimeout(() => {
selectElement.options[0].selected = true;
selectElement.options[1].selected = false;
selectElement.options[2].selected = false;
selectElement.options[3].selected = false;
selectElement.click();
}, 20);
});
});
});
describe("Updater()", function () {
beforeEach(() => {
let mocks = document.getElementById("mocks");
mocks.innerHTML = htmlNested;
});
describe("Nested Eventhandling", function () {
it("should not propagate child events to parent updater", function (done) {
const parentObserver = new ProxyObserver({});
const childObserver = new ProxyObserver({});
const parentUpdater = new Updater(
document.getElementById("parent"),
parentObserver,
);
const childUpdater = new Updater(
document.getElementById("child"),
childObserver,
);
parentUpdater.enableEventProcessing();
childUpdater.enableEventProcessing();
let parentNotified = false;
parentObserver.attachObserver(
new Observer(function () {
parentNotified = true;
}),
);
childObserver.attachObserver(
new Observer(function () {
try {
expect(childUpdater.getSubject()["childVal"]).to.equal("child");
expect(parentUpdater.getSubject()).to.not.have.property(
"childVal",
);
setTimeout(() => {
if (parentNotified) {
done(
new Error(
"parent updater should not react to child bind events",
),
);
} else {
done();
}
}, 10);
} catch (e) {
done(e);
}
}),
);
const childInput = document.getElementById("child-input");
childInput.value = "child";
childInput.click();
});
it("should handle parent events without triggering child updater", function (done) {
const parentObserver = new ProxyObserver({});
const childObserver = new ProxyObserver({});
const parentUpdater = new Updater(
document.getElementById("parent"),
parentObserver,
);
const childUpdater = new Updater(
document.getElementById("child"),
childObserver,
);
parentUpdater.enableEventProcessing();
childUpdater.enableEventProcessing();
let childNotified = false;
childObserver.attachObserver(
new Observer(function () {
childNotified = true;
}),
);
parentObserver.attachObserver(
new Observer(function () {
try {
expect(parentUpdater.getSubject()["parentVal"]).to.equal("parent");
expect(childUpdater.getSubject()).to.not.have.property(
"parentVal",
);
setTimeout(() => {
if (childNotified) {
done(
new Error(
"child updater should not react to parent bind events",
),
);
} else {
done();
}
}, 10);
} catch (e) {
done(e);
}
}),
);
const parentInput = document.getElementById("parent-input");
parentInput.value = "parent";
parentInput.click();
});
});
});
describe("Updater()", function () {
beforeEach(() => {
let mocks = document.getElementById("mocks");
mocks.innerHTML = html2;
});
describe("Replace", function () {
it("should add lower hello and HELLOyes!", function (done) {
let element = document.getElementById("test1");
let d = new Updater(element, {
text: "HALLO",
});
d.setCallback("myformatter", function (a) {
return a + "yes!";
});
d.run()
.then(() => {
setTimeout(() => {
expect(typeof d).is.equal("object");
expect(element).contain.html(
'<div data-monster-replace="path:text | tolower">hallo</div>',
);
expect(element).contain.html(
'<div data-monster-replace="path:text | call:myformatter">HALLOyes!</div>',
);
expect(element).contain.html(
'<div data-monster-replace="static:hello\\ ">hello </div>',
);
return done();
}, 100);
})
.catch((e) => {
done(new Error(e));
});
});
it("should dispose linked updaters when replacing a mounted stateful subtree", function (done) {
let mocks = document.getElementById("mocks");
mocks.innerHTML = htmlStatefulReplace;
const statefulElement = document.createElement("div");
statefulElement.innerHTML = "<span>stateful</span>";
let disposeCount = 0;
addToObjectLink(
statefulElement,
customElementUpdaterLinkSymbol,
new Set([
{
dispose() {
disposeCount++;
},
},
]),
);
let d = new Updater(document.getElementById("test-stateful"), {
content: statefulElement,
});
d.run()
.then(() => {
setTimeout(() => {
try {
expect(statefulElement.isConnected).to.equal(true);
d.getSubject().content = "<div>replaced</div>";
setTimeout(() => {
try {
expect(disposeCount).to.equal(1);
expect(statefulElement.isConnected).to.equal(false);
expect(document.getElementById("test-stateful")).contain.html(
"<div>replaced</div>",
);
done();
} catch (e) {
done(e);
}
}, 40);
} catch (e) {
done(e);
}
}, 0);
})
.catch((e) => {
done(new Error(e));
});
});
});
});
describe("Updater()", function () {
beforeEach(() => {
let mocks = document.getElementById("mocks");
mocks.innerHTML = htmlPatch;
});
describe("Patch", function () {
it("should patch primitive values as text content without parsing HTML", function (done) {
let element = document.getElementById("test-patch");
let d = new Updater(element, {
text: "<strong>Hello</strong>",
fragmentNode: document.createDocumentFragment(),
itemsArray: [],
keyedItems: [],
keyedDataItems: [],
items: [],
});
d.run()
.then(() => {
setTimeout(() => {
try {
const target = document.getElementById("patch-text");
expect(target.innerHTML).to.equal("<strong>Hello</strong>");
expect(target.textContent).to.equal("<strong>Hello</strong>");
done();
} catch (e) {
done(e);
}
}, 20);
})
.catch((e) => done(new Error(e)));
});
it("should patch HTMLElement values without reparsing HTML", function (done) {
let element = document.getElementById("test-patch");
const stateful = document.createElement("monster-message-state-button");
stateful.innerHTML = "Save";
let d = new Updater(element, {
text: "",
contentNode: stateful,
fragmentNode: document.createDocumentFragment(),
itemsArray: [],
keyedItems: [],
keyedDataItems: [],
items: [],
});
d.run()
.then(() => {
setTimeout(() => {
try {
const target = document.getElementById("patch-node");
expect(target.firstElementChild).to.equal(stateful);
expect(stateful.isConnected).to.equal(true);
done();
} catch (e) {
done(e);
}
}, 20);
})
.catch((e) => done(new Error(e)));
});
it("should rewrite nested patch paths in inserted templates", function (done) {
let element = document.getElementById("test-patch");
let d = new Updater(element, {
text: "",
contentNode: document.createElement("div"),
fragmentNode: document.createDocumentFragment(),
itemsArray: [],
keyedItems: [],
keyedDataItems: [],
items: [{ label: "One" }, { label: "Two" }],
});
d.run()
.then(() => {
setTimeout(() => {
try {
const list = document.getElementById("patch-list");
expect(list.children.length).to.equal(2);
expect(list.children[0].getAttribute("data-monster-patch")).to.equal(
"path:items.0.label",
);
expect(list.children[0].textContent).to.equal("One");
expect(list.children[1].getAttribute("data-monster-patch")).to.equal(
"path:items.1.label",
);
expect(list.children[1].textContent).to.equal("Two");
done();
} catch (e) {
done(e);
}
}, 20);
})
.catch((e) => done(new Error(e)));
});
it("should patch DocumentFragment values without reparsing HTML", function (done) {
let element = document.getElementById("test-patch");
const fragment = document.createDocumentFragment();
const first = document.createElement("span");
const second = document.createElement("strong");
first.textContent = "Alpha";
second.textContent = "Beta";
fragment.appendChild(first);
fragment.appendChild(second);
let d = new Updater(element, {
text: "",
contentNode: document.createElement("div"),
fragmentNode: fragment,
itemsArray: [],
keyedItems: [],
keyedDataItems: [],
items: [],
});
d.run()
.then(() => {
setTimeout(() => {
try {
const target = document.getElementById("patch-fragment");
expect(target.children.length).to.equal(2);
expect(target.children[0].textContent).to.equal("Alpha");
expect(target.children[1].textContent).to.equal("Beta");
done();
} catch (e) {
done(e);
}
}, 20);
})
.catch((e) => done(new Error(e)));
});
it("should patch arrays of primitives and nodes without using innerHTML", function (done) {
let element = document.getElementById("test-patch");
const badge = document.createElement("span");
badge.textContent = "Node";
let d = new Updater(element, {
text: "",
contentNode: document.createElement("div"),
fragmentNode: document.createDocumentFragment(),
itemsArray: ["Alpha", badge, "Omega"],
keyedItems: [],
keyedDataItems: [],
items: [],
});
d.run()
.then(() => {
setTimeout(() => {
try {
const target = document.getElementById("patch-array");
expect(target.childNodes.length).to.equal(3);
expect(target.childNodes[0].textContent).to.equal("Alpha");
expect(target.childNodes[1]).to.equal(badge);
expect(target.childNodes[2].textContent).to.equal("Omega");
expect(target.innerHTML).to.equal("Alpha<span>Node</span>Omega");
done();
} catch (e) {
done(e);
}
}, 20);
})
.catch((e) => done(new Error(e)));
});
it("should reorder keyed nodes without recreating them", function (done) {
let element = document.getElementById("test-patch");
const a = document.createElement("span");
const b = document.createElement("span");
const c = document.createElement("span");
a.dataset.key = "a";
b.dataset.key = "b";
c.dataset.key = "c";
a.textContent = "A";
b.textContent = "B";
c.textContent = "C";
let d = new Updater(element, {
text: "",
contentNode: document.createElement("div"),
fragmentNode: document.createDocumentFragment(),
itemsArray: [],
keyedItems: [a, b, c],
keyedDataItems: [],
items: [],
});
d.setCallback("getPatchKey", (value) => value?.dataset?.key);
d.run()
.then(() => {
setTimeout(() => {
try {
const target = document.getElementById("patch-keyed");
expect(Array.from(target.children)).to.deep.equal([a, b, c]);
d.getSubject().keyedItems = [c, a, b];
setTimeout(() => {
try {
expect(Array.from(target.children)).to.deep.equal([c, a, b]);
expect(target.children[0]).to.equal(c);
expect(target.children[1]).to.equal(a);
expect(target.children[2]).to.equal(b);
done();
} catch (e) {
done(e);
}
}, 20);
} catch (e) {
done(e);
}
}, 20);
})
.catch((e) => done(new Error(e)));
});
it("should remove stale keyed nodes and keep surviving node identity", function (done) {
let element = document.getElementById("test-patch");
const a = document.createElement("span");
const b = document.createElement("span");
const dNode = document.createElement("span");
a.dataset.key = "a";
b.dataset.key = "b";
dNode.dataset.key = "d";
a.textContent = "A";
b.textContent = "B";
dNode.textContent = "D";
let d = new Updater(element, {
text: "",
contentNode: document.createElement("div"),
fragmentNode: document.createDocumentFragment(),
itemsArray: [],
keyedItems: [a, b],
keyedDataItems: [],
items: [],
});
d.setCallback("getPatchKey", (value) => value?.dataset?.key);
d.run()
.then(() => {
setTimeout(() => {
try {
const target = document.getElementById("patch-keyed");
d.getSubject().keyedItems = [b, dNode];
setTimeout(() => {
try {
expect(Array.from(target.children)).to.deep.equal([b, dNode]);
expect(b.isConnected).to.equal(true);
expect(a.isConnected).to.equal(false);
done();
} catch (e) {
done(e);
}
}, 20);
} catch (e) {
done(e);
}
}, 20);
})
.catch((e) => done(new Error(e)));
});
it("should reorder keyed data items via patch-render without recreating text nodes", function (done) {
let element = document.getElementById("test-patch");
let d = new Updater(element, {
text: "",
contentNode: document.createElement("div"),
fragmentNode: document.createDocumentFragment(),
itemsArray: [],
keyedItems: [],
keyedDataItems: [
{ id: "a", label: "Alpha" },
{ id: "b", label: "Beta" },
{ id: "c", label: "Gamma" },
],
items: [],
});
d.run()
.then(() => {
setTimeout(() => {
try {
const target = document.getElementById("patch-keyed-render");
const initialNodes = Array.from(target.childNodes);
expect(initialNodes.map((node) => node.textContent)).to.deep.equal([
"Alpha",
"Beta",
"Gamma",
]);
d.getSubject().keyedDataItems = [
{ id: "c", label: "Gamma" },
{ id: "a", label: "Alpha" },
{ id: "b", label: "Beta" },
];
setTimeout(() => {
try {
const reorderedNodes = Array.from(target.childNodes);
expect(reorderedNodes.map((node) => node.textContent)).to.deep.equal([
"Gamma",
"Alpha",
"Beta",