diff --git a/XMLEditor.spec.ts b/XMLEditor.spec.ts index 928845c..f02989c 100644 --- a/XMLEditor.spec.ts +++ b/XMLEditor.spec.ts @@ -1,83 +1,84 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ -import { expect } from "@open-wc/testing"; +import { expect } from '@open-wc/testing'; -import { assert, property } from "fast-check"; +import { assert, property } from 'fast-check'; import { testDocs, UndoRedoTestCase, undoRedoTestCases, -} from "@omicronenergy/oscd-test-utils/arbitraries.js"; +} from '@omicronenergy/oscd-test-utils/arbitraries.js'; -import { sclDocString } from "@omicronenergy/oscd-test-utils/scl-sample-docs.js"; +import { sclDocString } from '@omicronenergy/oscd-test-utils/scl-sample-docs.js'; -import { Commit, EditV2, Transactor } from "@omicronenergy/oscd-api"; +import { Commit, EditV2, Transactor } from '@omicronenergy/oscd-api'; import { isSetTextContent, isSetAttributes, -} from "@omicronenergy/oscd-api/utils.js"; +} from '@omicronenergy/oscd-api/utils.js'; -import { XMLEditor } from "./XMLEditor.js"; +import { XMLEditor } from './XMLEditor.js'; +import sinon from 'sinon'; -describe("XMLEditor", () => { +describe('XMLEditor', () => { let editor: Transactor; let sclDoc: XMLDocument; beforeEach(() => { editor = new XMLEditor(); - sclDoc = new DOMParser().parseFromString(sclDocString, "application/xml"); + sclDoc = new DOMParser().parseFromString(sclDocString, 'application/xml'); }); - it("inserts an element on Insert", () => { + it('inserts an element on Insert', () => { const parent = sclDoc.documentElement; - const node = sclDoc.createElement("test"); - const reference = sclDoc.querySelector("Substation"); + const node = sclDoc.createElement('test'); + const reference = sclDoc.querySelector('Substation'); editor.commit({ parent, node, reference }); - expect(sclDoc.documentElement.querySelector("test")).to.have.property( - "nextSibling", + expect(sclDoc.documentElement.querySelector('test')).to.have.property( + 'nextSibling', reference, ); }); - it("removes an element on Remove", () => { - const node = sclDoc.querySelector("Substation")!; + it('removes an element on Remove', () => { + const node = sclDoc.querySelector('Substation')!; editor.commit({ node }); - expect(sclDoc.querySelector("Substation")).to.not.exist; + expect(sclDoc.querySelector('Substation')).to.not.exist; }); it("updates an element's attributes on SetAttributes", () => { - const element = sclDoc.querySelector("Substation")!; + const element = sclDoc.querySelector('Substation')!; editor.commit( { element, attributes: { - name: "A2", + name: 'A2', desc: null, - ["__proto__"]: "a string", // covers a rare edge case branch + ['__proto__']: 'a string', // covers a rare edge case branch }, attributesNS: { - "http://example.org/myns": { - "myns:attr": "value1", - "myns:attr2": "value1", + 'http://example.org/myns': { + 'myns:attr': 'value1', + 'myns:attr2': 'value1', }, }, }, {}, ); - expect(element.getAttribute("name")).to.equal("A2"); - expect(element.getAttribute("desc")).to.be.null; - expect(element.getAttribute("__proto__")).to.equal("a string"); - expect(element.getAttribute("myns:attr")).to.equal("value1"); - expect(element.getAttribute("myns:attr2")).to.equal("value1"); + expect(element.getAttribute('name')).to.equal('A2'); + expect(element.getAttribute('desc')).to.be.null; + expect(element.getAttribute('__proto__')).to.equal('a string'); + expect(element.getAttribute('myns:attr')).to.equal('value1'); + expect(element.getAttribute('myns:attr2')).to.equal('value1'); }); it("sets an element's textContent on SetTextContent", () => { - const element = sclDoc.querySelector("SCL")!; + const element = sclDoc.querySelector('SCL')!; - const newTextContent = "someNewTextContent"; + const newTextContent = 'someNewTextContent'; editor.commit({ element, textContent: newTextContent, @@ -86,62 +87,62 @@ describe("XMLEditor", () => { expect(element.textContent).to.equal(newTextContent); }); - it("records a commit history", () => { - const node = sclDoc.querySelector("Substation")!; + it('records a commit history', () => { + const node = sclDoc.querySelector('Substation')!; const edit = { node }; editor.commit(edit); expect(editor.past).to.have.lengthOf(1); expect(editor.past[0]) - .to.exist.and.property("redo") + .to.exist.and.property('redo') .to.have.lengthOf(1) .and.to.include(edit); }); - it("records a given title in the commit history", () => { - const node = sclDoc.querySelector("SCL")!; + it('records a given title in the commit history', () => { + const node = sclDoc.querySelector('SCL')!; editor.commit( { node, }, - { title: "delete everything" }, + { title: 'delete everything' }, ); expect(editor.past[editor.past.length - 1]).to.have.property( - "title", - "delete everything", + 'title', + 'delete everything', ); }); - it("squashes multiple edits into a single undoable edit", () => { - const element = sclDoc.querySelector("Substation")!; + it('squashes multiple edits into a single undoable edit', () => { + const element = sclDoc.querySelector('Substation')!; const edit1: EditV2 = { element, attributes: { - name: "A2", + name: 'A2', desc: null, - ["__proto__"]: "a string", // covers a rare edge case branch + ['__proto__']: 'a string', // covers a rare edge case branch }, attributesNS: { - "http://example.org/myns": { - "myns:attr": "value1", - "myns:attr2": "value1", + 'http://example.org/myns': { + 'myns:attr': 'value1', + 'myns:attr2': 'value1', }, - "http://example.org/myns2": { - attr: "value2", - attr2: "value2", + 'http://example.org/myns2': { + attr: 'value2', + attr2: 'value2', }, - "http://example.org/myns3": { - attr: "value3", - attr2: "value3", + 'http://example.org/myns3': { + attr: 'value3', + attr2: 'value3', }, }, }; const edit2: EditV2 = { element, - textContent: "someNewTextContent", + textContent: 'someNewTextContent', }; editor.commit(edit1, {}); @@ -154,11 +155,11 @@ describe("XMLEditor", () => { expect((history[0].undo as EditV2[])[1]).to.satisfy(isSetAttributes); }); - it("processes complex edits in the given order", () => { + it('processes complex edits in the given order', () => { const parent = sclDoc.documentElement; - const reference = sclDoc.querySelector("Substation"); - const node1 = sclDoc.createElement("test1"); - const node2 = sclDoc.createElement("test2"); + const reference = sclDoc.querySelector('Substation'); + const node1 = sclDoc.createElement('test1'); + const node2 = sclDoc.createElement('test2'); editor.commit( [ { parent, node: node1, reference }, @@ -166,39 +167,39 @@ describe("XMLEditor", () => { ], {}, ); - expect(sclDoc.documentElement.querySelector("test1")).to.have.property( - "nextSibling", + expect(sclDoc.documentElement.querySelector('test1')).to.have.property( + 'nextSibling', node2, ); - expect(sclDoc.documentElement.querySelector("test2")).to.have.property( - "nextSibling", + expect(sclDoc.documentElement.querySelector('test2')).to.have.property( + 'nextSibling', reference, ); }); - it("undoes a committed edit on undo() call", () => { - const node = sclDoc.querySelector("Substation")!; + it('undoes a committed edit on undo() call', () => { + const node = sclDoc.querySelector('Substation')!; const commit = editor.commit({ node }); const undone = editor.undo(); expect(undone).to.exist.and.to.equal(commit); - expect(sclDoc.querySelector("Substation")).to.exist; + expect(sclDoc.querySelector('Substation')).to.exist; }); - it("redoes an undone edit on redo() call", () => { - const node = sclDoc.querySelector("Substation")!; + it('redoes an undone edit on redo() call', () => { + const node = sclDoc.querySelector('Substation')!; const commit = editor.commit({ node }); editor.undo(); const redone = editor.redo(); expect(redone).to.exist.and.to.equal(commit); - expect(sclDoc.querySelector("Substation")).to.be.null; + expect(sclDoc.querySelector('Substation')).to.be.null; }); - it("undoes nothing at the beginning of the history", () => { - const node = sclDoc.querySelector("Substation")!; + it('undoes nothing at the beginning of the history', () => { + const node = sclDoc.querySelector('Substation')!; editor.commit({ node }); editor.undo(); @@ -207,8 +208,8 @@ describe("XMLEditor", () => { expect(secondUndo).to.not.exist; }); - it("redoes nothing at the end of the history", () => { - const node = sclDoc.querySelector("Substation")!; + it('redoes nothing at the end of the history', () => { + const node = sclDoc.querySelector('Substation')!; editor.commit({ node }); const redo = editor.redo(); @@ -216,8 +217,8 @@ describe("XMLEditor", () => { expect(redo).to.not.exist; }); - it("allows the user to subscribe to commits and to unsubscribe", () => { - const node = sclDoc.querySelector("Substation")!; + it('allows the user to subscribe to commits and to unsubscribe', () => { + const node = sclDoc.querySelector('Substation')!; const edit = { node }; let committed: Commit | undefined; let called = 0; @@ -226,45 +227,121 @@ describe("XMLEditor", () => { called++; }; const unsubscribe = editor.subscribe(callback); - editor.commit(edit, { title: "test" }); - expect(committed).to.exist.and.to.have.property("redo").to.include(edit); - expect(committed).to.have.property("title", "test"); + editor.commit(edit, { title: 'test' }); + expect(committed).to.exist.and.to.have.property('redo').to.include(edit); + expect(committed).to.have.property('title', 'test'); expect(called).to.equal(1); expect(editor.past).to.have.lengthOf(1); - editor.undo(); - expect(called).to.equal(1); - expect(editor.past).to.have.lengthOf(0); - expect(editor.future).to.have.lengthOf(1); - - editor.redo(); - expect(called).to.equal(1); - expect(editor.past).to.have.lengthOf(1); - expect(editor.future).to.have.lengthOf(0); - const unsubscribed = unsubscribe(); expect(unsubscribed).to.equal(callback); - editor.commit(edit, { title: "some other title, not test" }); - expect(committed).to.have.property("title", "test"); + editor.commit(edit, { title: 'some other title, not test' }); + expect(committed).to.have.property('title', 'test'); expect(called).to.equal(1); expect(editor.past).to.have.lengthOf(2); }); - describe("generally", () => { - it("undoes up to n edits on undo(n) call", () => + it('notifies subscribers on undo with the previous commit', () => { + const node = sclDoc.querySelector('Substation')!; + const edit = { node }; + + const subscriber = sinon.spy(); + + editor.subscribe(subscriber); + + const firstCommit = editor.commit(edit, { title: 'first' }); + sinon.assert.calledOnce(subscriber); + sinon.assert.calledWithExactly(subscriber, firstCommit); + + const secondCommit = editor.commit(edit, { title: 'second' }); + sinon.assert.calledTwice(subscriber); + sinon.assert.calledWithExactly(subscriber, secondCommit); + + editor.undo(); + sinon.assert.calledThrice(subscriber); + sinon.assert.calledWithExactly(subscriber, firstCommit); + }); + + it('notifies subscribers on redo with the redone commit', () => { + const node = sclDoc.querySelector('Substation')!; + const edit = { node }; + + const subscriber = sinon.spy(); + + editor.subscribe(subscriber); + + const firstCommit = editor.commit(edit, { title: 'first' }); + sinon.assert.calledOnce(subscriber); + sinon.assert.calledWithExactly(subscriber, firstCommit); + + const secondCommit = editor.commit(edit, { title: 'second' }); + sinon.assert.calledTwice(subscriber); + sinon.assert.calledWithExactly(subscriber, secondCommit); + + editor.undo(); + sinon.assert.calledThrice(subscriber); + sinon.assert.calledWithExactly(subscriber, firstCommit); + + editor.redo(); + sinon.assert.callCount(subscriber, 4); + sinon.assert.calledWithExactly(subscriber, secondCommit); + }); + + it('unsubscribes the correct subscriber among many', () => { + const node = sclDoc.querySelector('Substation')!; + const edit = { node }; + + const subscriber1 = sinon.spy(); + const subscriber2 = sinon.spy(); + const subscriber3 = sinon.spy(); + + const unsubscribe1 = editor.subscribe(subscriber1); + const unsubscribe2 = editor.subscribe(subscriber2); + const unsubscribe3 = editor.subscribe(subscriber3); + + editor.commit(edit, { title: 'test' }); + + sinon.assert.calledOnce(subscriber1); + sinon.assert.calledOnce(subscriber2); + sinon.assert.calledOnce(subscriber3); + + unsubscribe1(); + unsubscribe3(); + + subscriber1.resetHistory(); + subscriber2.resetHistory(); + subscriber3.resetHistory(); + + editor.commit(edit, { title: 'test2' }); + + sinon.assert.notCalled(subscriber1); + sinon.assert.calledOnce(subscriber2); + sinon.assert.notCalled(subscriber3); + + unsubscribe2(); + + subscriber2.resetHistory(); + + editor.commit(edit, { title: 'test3' }); + + sinon.assert.notCalled(subscriber2); + }); + + describe('generally', () => { + it('undoes up to n edits on undo(n) call', () => assert( property( - testDocs.chain((docs) => undoRedoTestCases(...docs)), + testDocs.chain(docs => undoRedoTestCases(...docs)), ({ doc1, doc2, edits, squash }: UndoRedoTestCase) => { - const [oldDoc1, oldDoc2] = [doc1, doc2].map((doc) => + const [oldDoc1, oldDoc2] = [doc1, doc2].map(doc => doc.cloneNode(true), ); edits.forEach((a: EditV2) => { try { editor.commit(a, { squash }); } catch (e) { - console.log("error", e); + console.log('error', e); } }); while (editor.past.length) editor.undo(); @@ -279,21 +356,21 @@ describe("XMLEditor", () => { ), )).timeout(20000); - it("redoes up to n edits on redo(n) call", () => + it('redoes up to n edits on redo(n) call', () => assert( property( - testDocs.chain((docs) => undoRedoTestCases(...docs)), + testDocs.chain(docs => undoRedoTestCases(...docs)), ({ doc1, doc2, edits }: UndoRedoTestCase) => { edits.forEach((a: EditV2) => { editor.commit(a); }); - const [oldDoc1, oldDoc2] = [doc1, doc2].map((doc) => + const [oldDoc1, oldDoc2] = [doc1, doc2].map(doc => new XMLSerializer().serializeToString(doc), ); while (editor.past.length) editor.undo(); while (editor.future.length) editor.redo(); - const [newDoc1, newDoc2] = [doc1, doc2].map((doc) => + const [newDoc1, newDoc2] = [doc1, doc2].map(doc => new XMLSerializer().serializeToString(doc), ); return oldDoc1 === newDoc1 && oldDoc2 === newDoc2; diff --git a/XMLEditor.ts b/XMLEditor.ts index d894e1e..b22645d 100644 --- a/XMLEditor.ts +++ b/XMLEditor.ts @@ -1,11 +1,13 @@ -import { handleEdit } from "./handleEdit.js"; +import { handleEdit } from './handleEdit.js'; import { Commit, CommitOptions, EditV2, TransactedCallback, Transactor, -} from "@omicronenergy/oscd-api"; +} from '@omicronenergy/oscd-api'; + +const EMPTY_COMMIT: Commit = { undo: [], redo: [] }; export class XMLEditor implements Transactor { past: Commit[] = []; @@ -27,7 +29,7 @@ export class XMLEditor implements Transactor { if (squash && this.past.length) this.past.pop(); this.past.push(commit); this.future = []; - this.#subscribers.forEach((subscriber) => subscriber(commit)); + this.#subscribers.forEach(subscriber => subscriber(commit)); return commit; } @@ -36,6 +38,8 @@ export class XMLEditor implements Transactor { if (!commit) return; handleEdit(commit.undo); this.future.unshift(commit); + const previousCommit = this.past[this.past.length - 1] || EMPTY_COMMIT; + this.#subscribers.forEach(subscriber => subscriber(previousCommit)); return commit; } @@ -44,6 +48,7 @@ export class XMLEditor implements Transactor { if (!commit) return; handleEdit(commit.redo); this.past.push(commit); + this.#subscribers.forEach(subscriber => subscriber(commit)); return commit; } @@ -52,10 +57,11 @@ export class XMLEditor implements Transactor { subscribe( txCallback: TransactedCallback, ): () => TransactedCallback { - const subscriberCount = this.#subscribers.length; this.#subscribers.push(txCallback); return () => { - this.#subscribers.splice(subscriberCount, 1); + this.#subscribers = this.#subscribers.filter( + subscriber => subscriber !== txCallback, + ); return txCallback; }; } diff --git a/package-lock.json b/package-lock.json index d9fc2c2..2bf3678 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "prettier": "^3.5.3", "rimraf": "^3.0.2", "semantic-release": "24.1.2", + "sinon": "^21.0.0", "typedoc": "^0.28.5", "typescript": "^5.6.2" } @@ -2058,6 +2059,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -12163,6 +12205,34 @@ "node": ">=4" } }, + "node_modules/sinon": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", + "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.5", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/skin-tone": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", @@ -12806,6 +12876,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", diff --git a/package.json b/package.json index fc0a80e..4b60bfc 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,9 @@ "prettier": "^3.5.3", "rimraf": "^3.0.2", "semantic-release": "24.1.2", - "typescript": "^5.6.2", - "typedoc": "^0.28.5" + "sinon": "^21.0.0", + "typedoc": "^0.28.5", + "typescript": "^5.6.2" }, "dependencies": { "@omicronenergy/oscd-api": "^0.1.0",