UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

1,484 lines (1,301 loc) 67 kB
"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("&lt;strong&gt;Hello&lt;/strong&gt;"); 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",