Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 295 additions & 16 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@omicronenergy/oscd-test-utils": "^0.0.11",
"@open-wc/testing": "3.2.2",
"@types/mocha": "^10.0.8",
"@typescript-eslint/eslint-plugin": "^8.8.0",
Expand All @@ -51,8 +52,7 @@
"typescript": "^5.6.2"
},
"dependencies": {
"@omicronenergy/oscd-api": "^0.1.0",
"@omicronenergy/oscd-test-utils": "^0.0.6"
"@openscd/oscd-api": "^0.1.6"
},
"prettier": {
"singleQuote": true,
Expand Down
16 changes: 8 additions & 8 deletions XMLEditor.spec.ts → src/XMLEditor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,20 @@ import {
testDocs,
UndoRedoTestCase,
undoRedoTestCases,
} from '@omicronenergy/oscd-test-utils/arbitraries.js';
} from '@omicronenergy/oscd-test-utils';

import { sclDocString } from '@omicronenergy/oscd-test-utils/scl-sample-docs.js';
import { Commit, EditV2, Transactor } from '@openscd/oscd-api';

import { Commit, EditV2, Transactor } from '@omicronenergy/oscd-api';

import {
isSetTextContent,
isSetAttributes,
} from '@omicronenergy/oscd-api/utils.js';
import { isSetTextContent, isSetAttributes } from '@openscd/oscd-api/utils.js';

import { XMLEditor } from './XMLEditor.js';
import sinon from 'sinon';

export const sclDocString = `<?xml version="1.0" encoding="UTF-8"?>
<SCL version="2007" revision="B" xmlns="http://www.iec.ch/61850/2003/SCL" xmlns:ens1="http://example.org/somePreexistingExtensionNamespace">
<Substation name="A1" desc="test substation"></Substation>
</SCL>`;

describe('XMLEditor', () => {
let editor: Transactor<EditV2>;
let sclDoc: XMLDocument;
Expand Down
6 changes: 3 additions & 3 deletions XMLEditor.ts → src/XMLEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import {
EditV2,
TransactedCallback,
Transactor,
} from '@omicronenergy/oscd-api';
} from '@openscd/oscd-api';

const EMPTY_COMMIT: Commit<EditV2> = { undo: [], redo: [] };
const EMPTY_COMMIT: Commit<EditV2> = { undo: [], redo: [], time: Date.now() };

export class XMLEditor implements Transactor<EditV2> {
past: Commit<EditV2>[] = [];
Expand All @@ -20,7 +20,7 @@ export class XMLEditor implements Transactor<EditV2> {
const commit: Commit<EditV2> =
squash && this.past.length
? this.past[this.past.length - 1]
: { undo: [], redo: [] };
: { undo: [], redo: [], time: Date.now() };
const undo = handleEdit(change);
// typed as per https://github.com/microsoft/TypeScript/issues/49280#issuecomment-1144181818 recommendation:
commit.undo.unshift(...[undo].flat(Infinity as 1));
Expand Down
155 changes: 77 additions & 78 deletions handleEdit.spec.ts → src/handleEdit.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
import { expect } from "@open-wc/testing";
import { expect } from '@open-wc/testing';

import {
insert,
Expand All @@ -11,35 +11,34 @@ import {
UndoRedoTestCase,
undoRedoTestCases,
xmlAttributeName,
} from "@omicronenergy/oscd-test-utils/arbitraries.js";
} from '@omicronenergy/oscd-test-utils';

import { sclDocString } from "@omicronenergy/oscd-test-utils/scl-sample-docs.js";
import { EditV2, Insert } from '@openscd/oscd-api';
import { isEditV2 } from '@openscd/oscd-api/utils.js';

import { EditV2, Insert } from "@omicronenergy/oscd-api";
import { isEditV2 } from "@omicronenergy/oscd-api/utils.js";
import { handleEdit } from './handleEdit.js';

import { handleEdit } from "./handleEdit.js";
import { assert, property } from 'fast-check';
import { sclDocString } from './XMLEditor.spec.js';

import { assert, property } from "fast-check";

it("fails at distinguishing EditV2", () => {
expect(isEditV2([{ node: "notanode", parent: "notanode", reference: 42 }])).to
it('fails at distinguishing EditV2', () => {
expect(isEditV2([{ node: 'notanode', parent: 'notanode', reference: 42 }])).to
.be.false;
});

describe("handleEdit", () => {
describe('handleEdit', () => {
let sclDoc: XMLDocument;

beforeEach(async () => {
sclDoc = new DOMParser().parseFromString(sclDocString, "application/xml");
sclDoc = new DOMParser().parseFromString(sclDocString, 'application/xml');
});

it("does nothing given invalid input", () => {
it('does nothing given invalid input', () => {
const sclDocStringBefore = new XMLSerializer().serializeToString(sclDoc);

const parent = sclDoc.documentElement;
const node = sclDoc.createElement("test");
const reference = sclDoc.querySelector("Substation");
const node = sclDoc.createElement('test');
const reference = sclDoc.querySelector('Substation');
const invalidedit = {
parent,
someinvalidkey: node,
Expand All @@ -50,71 +49,71 @@ describe("handleEdit", () => {

const sclDocStringAfter = new XMLSerializer().serializeToString(sclDoc);

expect(undoEdit).to.be.an("array").that.is.empty;
expect(undoEdit).to.be.an('array').that.is.empty;
expect(sclDocStringBefore).to.equal(sclDocStringAfter);
});

it("inserts an element given an Insert", () => {
it('inserts an element given an 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');
handleEdit({ 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 given a Remove", () => {
const node = sclDoc.querySelector("Substation")!;
it('removes an element given a Remove', () => {
const node = sclDoc.querySelector('Substation')!;
handleEdit({ node });
expect(sclDoc.querySelector("Substation")).to.not.exist;
expect(sclDoc.querySelector('Substation')).to.not.exist;
});

it("updates an element's attributes given a SetAttributes", () => {
const element = sclDoc.querySelector("Substation")!;
const element = sclDoc.querySelector('Substation')!;
handleEdit({
element,
attributes: {
name: "A2",
name: 'A2',
desc: null,
["__proto__"]: "a string", // covers a rare edge case branch
"42isnotValid": "something",
['__proto__']: 'a string', // covers a rare edge case branch
'42isnotValid': 'something',
},
attributesNS: {
"http://example.org/somePreexistingExtensionNamespace": {
"ens1:test": null,
'http://example.org/somePreexistingExtensionNamespace': {
'ens1:test': null,
},
"http://example.org/myns": {
"myns:attr": "value1",
"myns:attr2": "value1",
"myns:-is-not-valid-either": "something",
'http://example.org/myns': {
'myns:attr': 'value1',
'myns:attr2': 'value1',
'myns:-is-not-valid-either': 'something',
},
},
});

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("42isnotValid")).to.not.exist;
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('42isnotValid')).to.not.exist;
expect(
element.getAttributeNS("http://example.org/myns", "-is-not-valid-either"),
element.getAttributeNS('http://example.org/myns', '-is-not-valid-either'),
).to.not.exist;
expect(element.getAttribute("myns:-is-not-valid-either")).to.not.exist;
expect(element.getAttribute('myns:-is-not-valid-either')).to.not.exist;
expect(
element.getAttributeNS(
"http://example.org/somePreexistingExtensionNamespace",
"test",
'http://example.org/somePreexistingExtensionNamespace',
'test',
),
).to.be.null;
});

it("sets an element's textContent given a SetTextContent", () => {
const element = sclDoc.querySelector("SCL")!;
const element = sclDoc.querySelector('SCL')!;

const newTextContent = "someNewTextContent";
const newTextContent = 'someNewTextContent';
handleEdit({
element,
textContent: newTextContent,
Expand All @@ -123,49 +122,49 @@ describe("handleEdit", () => {
expect(element.textContent).to.equal(newTextContent);
});

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');
handleEdit([
{ parent, node: node1, reference },
{ parent, node: node2, reference },
]);
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("returns an undo edit that undoes the original edit", () => {
const node = sclDoc.querySelector("Substation")!;
it('returns an undo edit that undoes the original edit', () => {
const node = sclDoc.querySelector('Substation')!;
const undoEdit = handleEdit({ node }); // do edit
handleEdit(undoEdit); // undo edit
expect(sclDoc.querySelector("Substation")).to.exist;
expect(sclDoc.querySelector('Substation')).to.exist;
});

it("returns the original edit when called on an undo edit", () => {
const node = sclDoc.querySelector("Substation")!;
it('returns the original edit when called on an undo edit', () => {
const node = sclDoc.querySelector('Substation')!;
const undoEdit = handleEdit({ node });
const redoEdit = handleEdit(undoEdit);
handleEdit(redoEdit);
expect(sclDoc.querySelector("Substation")).to.not.exist;
expect(sclDoc.querySelector('Substation')).to.not.exist;
});

describe("generally", () => {
it("inserts elements given Inserts", () =>
describe('generally', () => {
it('inserts elements given Inserts', () =>
assert(
property(
testDocs.chain(([doc1, doc2]) => {
const nodes = doc1.nodes.concat(doc2.nodes);
return insert(nodes);
}),
(edit) => {
edit => {
handleEdit(edit);
if (isValidInsert(edit))
return (
Expand All @@ -184,24 +183,24 @@ describe("handleEdit", () => {
const nodes = doc1.nodes.concat(doc2.nodes);
return setTextContent(nodes);
}),
(edit) => {
edit => {
handleEdit(edit);

return edit.element.textContent === edit.textContent;
},
),
));

it("updates attributes given SetAttributes", () =>
it('updates attributes given SetAttributes', () =>
assert(
property(
testDocs.chain(([{ nodes }]) => setAttributes(nodes)),
(edit) => {
edit => {
handleEdit(edit);
const attributesHandledCorrectly = edit.attributes
? Object.entries(edit.attributes)
.filter(([name]) => xmlAttributeName.test(name))
.map((entry) => entry as [string, string | null])
.map(entry => entry as [string, string | null])
.every(
([name, value]) =>
edit.element.getAttribute(name) === value,
Expand All @@ -210,15 +209,15 @@ describe("handleEdit", () => {
const attributesNSHandledCorrectly = edit.attributesNS
? Object.entries(edit.attributesNS)
.map(
(entry) => entry as [string, Record<string, string | null>],
entry => entry as [string, Record<string, string | null>],
)
.every(([ns, attributes]) => {
const unprefixedAttributes = Object.fromEntries(
Object.entries(attributes)
.filter(([name]) => xmlAttributeName.test(name))
.map((entry) => entry as [string, string | null])
.map(entry => entry as [string, string | null])
.map(([name, value]) => [
name.split(":", 2).pop(),
name.split(':', 2).pop(),
value,
])
.filter(([name]) => name),
Expand All @@ -234,7 +233,7 @@ describe("handleEdit", () => {
),
)).timeout(20000);

it("removes elements given Removes", () =>
it('removes elements given Removes', () =>
assert(
property(
testDocs.chain(([{ nodes }]) => remove(nodes)),
Expand All @@ -245,12 +244,12 @@ describe("handleEdit", () => {
),
));

it("leaves the document unchanged after undoing all edits", () =>
it('leaves the document unchanged after undoing all edits', () =>
assert(
property(
testDocs.chain((docs) => undoRedoTestCases(...docs)),
testDocs.chain(docs => undoRedoTestCases(...docs)),
({ doc1, doc2, edits }: UndoRedoTestCase) => {
const [oldDoc1, oldDoc2] = [doc1, doc2].map((doc) =>
const [oldDoc1, oldDoc2] = [doc1, doc2].map(doc =>
doc.cloneNode(true),
);
const undoEdits: EditV2[] = [];
Expand All @@ -270,16 +269,16 @@ describe("handleEdit", () => {
),
)).timeout(20000);

it("changes the document the same way when redoing undone edits", () =>
it('changes the document the same way when redoing undone edits', () =>
assert(
property(
testDocs.chain((docs) => undoRedoTestCases(...docs)),
testDocs.chain(docs => undoRedoTestCases(...docs)),
({ doc1, doc2, edits }: UndoRedoTestCase) => {
const undoEdits: EditV2[] = [];
edits.forEach((a: EditV2) => {
undoEdits.unshift(handleEdit(a));
});
const [oldDoc1, oldDoc2] = [doc1, doc2].map((doc) =>
const [oldDoc1, oldDoc2] = [doc1, doc2].map(doc =>
new XMLSerializer().serializeToString(doc),
);
const redoEdits: EditV2[] = [];
Expand All @@ -288,7 +287,7 @@ describe("handleEdit", () => {
redoEdits.unshift(handleEdit(undoEdits));
handleEdit(redoEdits);
}
const [newDoc1, newDoc2] = [doc1, doc2].map((doc) =>
const [newDoc1, newDoc2] = [doc1, doc2].map(doc =>
new XMLSerializer().serializeToString(doc),
);
return oldDoc1 === newDoc1 && oldDoc2 === newDoc2;
Expand Down
Loading