import { expect, fixture, html } from '@open-wc/testing';
import {
Arbitrary,
array,
assert,
constant,
constantFrom,
dictionary,
oneof,
property,
record,
string as stringArbitrary,
stringOf,
tuple,
unicode,
webUrl,
} from 'fast-check';
import {
Edit,
Insert,
isNamespaced,
NamespacedAttributeValue,
newEditEvent,
newOpenEvent,
Remove,
Update,
} from './foundation.js';
import type { OpenSCD } from './open-scd.js';
import './open-scd.js';
export namespace util {
export const xmlAttributeName =
/^(?!xml|Xml|xMl|xmL|XMl|xML|XmL|XML)[A-Za-z_][A-Za-z0-9-_.]*(:[A-Za-z_][A-Za-z0-9-_.]*)?$/;
export function descendants(parent: Element | XMLDocument): Node[] {
return (Array.from(parent.childNodes) as Node[]).concat(
...Array.from(parent.children).map(child => descendants(child))
);
}
export const sclDocString = `
`;
const testDocStrings = [
sclDocString,
`
SomeText
SomeMoreText
`,
`
SomeText
SomeMoreText
`,
];
export type TestDoc = { doc: XMLDocument; nodes: Node[] };
export const testDocs = tuple(
constantFrom(...testDocStrings),
constantFrom(...testDocStrings)
)
.map(strs =>
strs.map(str => new DOMParser().parseFromString(str, 'application/xml'))
)
.map(docs =>
docs.map(doc => ({ doc, nodes: descendants(doc).concat([doc]) }))
) as Arbitrary<[TestDoc, TestDoc]>;
export function remove(nodes: Node[]): Arbitrary {
const node = oneof(
{ arbitrary: constantFrom(...nodes), weight: nodes.length },
testDocs.chain(docs => constantFrom(...docs.map(d => d.doc)))
);
return record({ node });
}
export function insert(nodes: Node[]): Arbitrary {
const references = (nodes as (Node | null)[]).concat([null]);
const parent = constantFrom(...nodes);
const node = constantFrom(...nodes);
const reference = constantFrom(...references);
return record({ parent, node, reference });
}
const namespacedValue = record({
value: oneof(
stringOf(oneof({ arbitrary: unicode(), weight: 10 }, constant(':'))),
constant(null)
),
namespaceURI: oneof({ arbitrary: webUrl(), weight: 10 }, constant(null)),
});
export function update(nodes: Node[]): Arbitrary {
const element = >(
constantFrom(...nodes.filter(nd => nd.nodeType === Node.ELEMENT_NODE))
);
const attributes = dictionary(
oneof(stringArbitrary(), constant('colliding-attribute-name')),
oneof(stringArbitrary(), constant(null), namespacedValue)
);
return record({ element, attributes });
}
export function simpleEdit(
nodes: Node[]
): Arbitrary {
return oneof(remove(nodes), insert(nodes), update(nodes));
}
export function complexEdit(nodes: Node[]): Arbitrary {
return array(simpleEdit(nodes));
}
export function edit(nodes: Node[]): Arbitrary {
return oneof(
{ arbitrary: simpleEdit(nodes), weight: 2 },
complexEdit(nodes)
);
}
/** A series of arbitrary edits that allow us to test undo and redo */
export type UndoRedoTestCase = {
doc1: XMLDocument;
doc2: XMLDocument;
edits: Edit[];
};
export function undoRedoTestCases(
testDoc1: TestDoc,
testDoc2: TestDoc
): Arbitrary {
const nodes = testDoc1.nodes.concat(testDoc2.nodes);
return record({
doc1: constant(testDoc1.doc),
doc2: constant(testDoc2.doc),
edits: array(edit(nodes)),
});
}
export function isParentNode(node: Node): node is ParentNode {
return (
node instanceof Element ||
node instanceof Document ||
node instanceof DocumentFragment
);
}
export function isParentOf(parent: Node, node: Node | null) {
return (
isParentNode(parent) &&
(node === null ||
Array.from(parent.childNodes).includes(node as ChildNode))
);
}
export function isValidInsert({ parent, node, reference }: Insert) {
return (
node !== reference &&
isParentOf(parent, reference) &&
!node.contains(parent) &&
![Node.DOCUMENT_NODE, Node.DOCUMENT_TYPE_NODE].some(
nodeType => node.nodeType === nodeType
) &&
!(
parent instanceof Document &&
(parent.documentElement || !(node instanceof Element))
)
);
}
}
describe('Editing Element', () => {
let editor: OpenSCD;
let sclDoc: XMLDocument;
beforeEach(async () => {
editor = await fixture(html``);
sclDoc = new DOMParser().parseFromString(
util.sclDocString,
'application/xml'
);
});
it('loads a document on OpenDocEvent', async () => {
editor.dispatchEvent(newOpenEvent(sclDoc, 'test.scd'));
await editor.updateComplete;
expect(editor.doc).to.equal(sclDoc);
expect(editor.docName).to.equal('test.scd');
});
it('inserts an element on Insert', () => {
const parent = sclDoc.documentElement;
const node = sclDoc.createElement('test');
const reference = sclDoc.querySelector('Substation');
editor.dispatchEvent(newEditEvent({ parent, node, reference }));
expect(sclDoc.documentElement.querySelector('test')).to.have.property(
'nextSibling',
reference
);
});
it('removes an element on Remove', () => {
const node = sclDoc.querySelector('Substation')!;
editor.dispatchEvent(newEditEvent({ node }));
expect(sclDoc.querySelector('Substation')).to.not.exist;
});
it("updates an element's attributes on Update", () => {
const element = sclDoc.querySelector('Substation')!;
editor.dispatchEvent(
newEditEvent({
element,
attributes: {
name: 'A2',
desc: null,
['__proto__']: 'a string', // covers a rare edge case branch
'myns:attr': {
value: 'namespaced value',
namespaceURI: 'http://example.org/myns',
},
},
})
);
expect(element).to.have.attribute('name', 'A2');
expect(element).to.not.have.attribute('desc');
expect(element).to.have.attribute('__proto__', 'a string');
expect(element).to.have.attribute('myns:attr', 'namespaced value');
});
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');
editor.dispatchEvent(
newEditEvent([
{ parent, node: node1, reference },
{ parent, node: node2, reference },
])
);
expect(sclDoc.documentElement.querySelector('test1')).to.have.property(
'nextSibling',
node2
);
expect(sclDoc.documentElement.querySelector('test2')).to.have.property(
'nextSibling',
reference
);
});
it('undoes a committed edit on undo() call', () => {
const node = sclDoc.querySelector('Substation')!;
editor.dispatchEvent(newEditEvent({ node }));
editor.undo();
expect(sclDoc.querySelector('Substation')).to.exist;
});
it('redoes an undone edit on redo() call', () => {
const node = sclDoc.querySelector('Substation')!;
editor.dispatchEvent(newEditEvent({ node }));
editor.undo();
editor.redo();
expect(sclDoc.querySelector('Substation')).to.not.exist;
});
describe('generally', () => {
it('inserts elements on Insert edit events', () =>
assert(
property(
util.testDocs.chain(([doc1, doc2]) => {
const nodes = doc1.nodes.concat(doc2.nodes);
return util.insert(nodes);
}),
edit => {
editor.dispatchEvent(newEditEvent(edit));
if (util.isValidInsert(edit))
return (
edit.node.parentElement === edit.parent &&
edit.node.nextSibling === edit.reference
);
return true;
}
)
));
it('updates default namespace attributes on Update edit events', () =>
assert(
property(
util.testDocs.chain(([{ nodes }]) => util.update(nodes)),
edit => {
editor.dispatchEvent(newEditEvent(edit));
return Object.entries(edit.attributes)
.filter(
([name, value]) =>
util.xmlAttributeName.test(name) && !isNamespaced(value!)
)
.every(
([name, value]) => edit.element.getAttribute(name) === value
);
}
)
));
it('updates namespaced attributes on Update edit events', () =>
assert(
property(
util.testDocs.chain(([{ nodes }]) => util.update(nodes)),
edit => {
editor.dispatchEvent(newEditEvent(edit));
return Object.entries(edit.attributes)
.filter(
([name, value]) =>
util.xmlAttributeName.test(name) &&
isNamespaced(value!) &&
value.namespaceURI
)
.map(entry => entry as [string, NamespacedAttributeValue])
.every(
([name, { value, namespaceURI }]) =>
edit.element.getAttributeNS(
namespaceURI,
name.includes(':') ? name.split(':', 2)[1] : name
) === value
);
}
)
));
it('removes elements on Remove edit events', () =>
assert(
property(
util.testDocs.chain(([{ nodes }]) => util.remove(nodes)),
({ node }) => {
editor.dispatchEvent(newEditEvent({ node }));
return !node.parentNode;
}
)
));
it('undoes up to n edits on undo(n) call', () =>
assert(
property(
util.testDocs.chain(docs => util.undoRedoTestCases(...docs)),
({ doc1, doc2, edits }: util.UndoRedoTestCase) => {
const [oldDoc1, oldDoc2] = [doc1, doc2].map(doc =>
doc.cloneNode(true)
);
edits.forEach((a: Edit) => {
editor.dispatchEvent(newEditEvent(a));
});
if (edits.length) editor.undo(edits.length);
expect(doc1).to.satisfy((doc: XMLDocument) =>
doc.isEqualNode(oldDoc1)
);
expect(doc2).to.satisfy((doc: XMLDocument) =>
doc.isEqualNode(oldDoc2)
);
return true;
}
)
));
it('redoes up to n edits on redo(n) call', () =>
assert(
property(
util.testDocs.chain(docs => util.undoRedoTestCases(...docs)),
({ doc1, doc2, edits }: util.UndoRedoTestCase) => {
edits.forEach((a: Edit) => {
editor.dispatchEvent(newEditEvent(a));
});
const [oldDoc1, oldDoc2] = [doc1, doc2].map(doc =>
new XMLSerializer().serializeToString(doc)
);
if (edits.length) {
editor.undo(edits.length + 1);
editor.redo(edits.length + 1);
}
const [newDoc1, newDoc2] = [doc1, doc2].map(doc =>
new XMLSerializer().serializeToString(doc)
);
return oldDoc1 === newDoc1 && oldDoc2 === newDoc2;
}
)
));
});
});