patching works in Spark!

This commit is contained in:
David Greenspan
2012-08-02 19:39:00 -07:00
parent 0ad6393fb2
commit 4f52054cd9
5 changed files with 707 additions and 157 deletions

View File

@@ -121,154 +121,6 @@ Tinytest.add("liveui - tables", function(test) {
});
Tinytest.add("liveui - preserved nodes (diff/patch)", function(test) {
var rand;
var randomNodeList = function(optParentTag, depth) {
var atTopLevel = ! optParentTag;
var len = rand.nextIntBetween(atTopLevel ? 1 : 0, 6);
var buf = [];
for(var i=0; i<len; i++)
buf.push(randomNode(optParentTag, depth));
return buf;
};
var randomNode = function(optParentTag, depth) {
var n = {};
if (rand.nextBoolean()) {
// text node
n.text = rand.nextIdentifier(2);
} else {
n.tagName = rand.nextChoice((function() {
switch (optParentTag) {
case "p": return ['b', 'i', 'u'];
case "b": return ['i', 'u'];
case "i": return ['u'];
case "u": case "span": return ['span'];
default: return ['div', 'ins', 'center', 'p'];
}
})());
if (rand.nextBoolean())
n.id = rand.nextIdentifier();
if (rand.nextBoolean())
n.name = rand.nextIdentifier();
if (depth === 0) {
n.children = [];
} else {
n.children = randomNodeList(n.tagName, depth-1);
}
}
var existence = rand.nextChoice([[true, true], [false, true], [true, false]]);
n.existsBefore = existence[0];
n.existsAfter = existence[1];
return n;
};
var nodeListToHtml = function(list, is_after, optBuf) {
var buf = (optBuf || []);
_.each(list, function(n) {
if (is_after ? n.existsAfter : n.existsBefore) {
if (n.text) {
buf.push(n.text);
} else {
buf.push('<', n.tagName);
if (n.id)
buf.push(' id="', n.id, '"');
if (n.name)
buf.push(' name="', n.name, '"');
buf.push('>');
nodeListToHtml(n.children, is_after, buf);
buf.push('</', n.tagName, '>');
}
}
});
return optBuf ? null : buf.join('');
};
var fillInElementIdentities = function(list, parent, is_after) {
var elementsInList = _.filter(
list,
function(x) {
return (is_after ? x.existsAfter : x.existsBefore) && x.tagName;
});
var elementsInDom = _.filter(parent.childNodes,
function(x) { return x.nodeType === 1; });
test.equal(elementsInList.length, elementsInDom.length);
for(var i=0; i<elementsInList.length; i++) {
elementsInList[i].node = elementsInDom[i];
fillInElementIdentities(elementsInList[i].children,
elementsInDom[i]);
}
};
var getParentChain = function(node) {
var buf = [];
while (node) {
buf.push(node);
node = node.parentNode;
}
return buf;
};
var isSameElements = function(a, b) {
if (a.length !== b.length)
return false;
for(var i=0; i<a.length; i++) {
if (a[i] !== b[i])
return false;
}
return true;
};
var collectLabeledNodeData = function(list, optArray) {
var buf = optArray || [];
_.each(list, function(x) {
if (x.tagName && x.existsBefore && x.existsAfter) {
if (x.name || x.id) {
buf.push({ node: x.node, parents: getParentChain(x.node) });
}
collectLabeledNodeData(x.children, buf);
}
});
return buf;
};
for(var i=0; i<5; i++) {
// Use non-deterministic randomness so we can have a shorter fuzz
// test (fewer iterations). For deterministic (fully seeded)
// randomness, remove the call to Math.random().
rand = new SeededRandom("preserved nodes "+i+" "+Math.random());
var R = ReactiveVar(false);
var structure = randomNodeList(null, 6);
var frag = WrappedFrag(Meteor.ui.render(function() {
return nodeListToHtml(structure, R.get());
}, {preserve: legacyLabels})).hold();
test.equal(frag.html(), nodeListToHtml(structure, false) || "<!---->");
fillInElementIdentities(structure, frag.node());
var labeledNodes = collectLabeledNodeData(structure);
R.set(true);
Meteor.flush();
test.equal(frag.html(), nodeListToHtml(structure, true) || "<!---->");
_.each(labeledNodes, function(x) {
test.isTrue(isSameElements(x.parents, getParentChain(x.node)));
});
frag.release();
Meteor.flush();
test.equal(R.numListeners(), 0);
}
});
Tinytest.add("liveui - copied attributes", function(test) {
// make sure attributes are correctly changed (i.e. copied)

View File

@@ -13,7 +13,7 @@ Package.on_use(function (api) {
// you still want the event object normalization that jquery provides?)
api.use('jquery');
api.add_files(['spark.js', 'convenience.js'], 'client');
api.add_files(['spark.js', 'patch.js', 'convenience.js'], 'client');
/*
api.add_files(['liveevents_w3c.js', 'liveevents_now3c.js'], 'client');
api.add_files(['liveevents.js'], 'client');

478
packages/spark/patch.js Normal file
View File

@@ -0,0 +1,478 @@
Spark._patch = function(tgtParent, srcParent, tgtBefore, tgtAfter, preservations) {
var copyFunc = function(t, s) {
LiveRange.transplant_tag(Spark._TAG, t, s);
};
var patcher = new Spark._Patcher(
tgtParent, srcParent, tgtBefore, tgtAfter);
var visitNodes = function(parent, before, after, func) {
for(var n = before ? before.nextSibling : parent.firstChild;
n && n !== after;
n = n.nextSibling) {
if (func(n) !== false && n.firstChild)
visitNodes(n, null, null, func);
}
};
var lastTgtMatch = null;
visitNodes(srcParent, null, null, function(src) {
// XXX inefficient to scan for match for every node!
// We could at least skip non-element nodes, except for "range matches"
// used for constant chunks, which may begin on a non-element.
// But really this shouldn't be a linear search.
var pres = _.find(preservations, function (p) {
// find preserved region starting at `src`, if any
return p.type === 'region' && p.newRange.firstNode() === src;
}) || _.find(preservations, function (p) {
// else, find preservation of `src`
return p.type === 'node' && p.to === src;
});
if (pres) {
var tgt = (pres.type === 'region' ? pres.fromStart : pres.from);
if (! lastTgtMatch ||
DomUtils.elementOrder(lastTgtMatch, tgt) > 0) {
if (pres.type === 'region') {
// preserved region for constant landmark
if (patcher.match(pres.fromStart, pres.newRange.firstNode(), null, true)) {
patcher.skipToSiblings(pres.fromEnd, pres.newRange.lastNode());
// without knowing or caring what DOM nodes are in pres.newRange,
// transplant the range data to pres.fromStart and pres.fromEnd
// (including references to enclosing ranges).
LiveRange.transplant_range(
pres.fromStart, pres.fromEnd, pres.newRange);
}
} else if (pres.type === 'node') {
if (patcher.match(tgt, src, copyFunc)) {
// match succeeded
lastTgtMatch = tgt;
if (tgt.firstChild || src.firstChild) {
// Don't patch contents of TEXTAREA tag,
// which are only the initial contents but
// may affect the tag's .value in IE.
if (tgt.nodeName !== "TEXTAREA") {
// recurse!
Spark._patch(tgt, src, null, null, preservations);
}
}
return false; // tell visitNodes not to recurse
}
}
}
}
return true;
});
patcher.finish();
};
// A Patcher manages the controlled replacement of a region of the DOM.
// The target region is changed in place to match the source region.
//
// The target region consists of the children of tgtParent, extending from
// the child after tgtBefore to the child before tgtAfter. A null
// or absent tgtBefore or tgtAfter represents the beginning or end
// of tgtParent's children. The source region consists of all children
// of srcParent, which may be a DocumentFragment.
//
// To use a new Patcher, call `match` zero or more times followed by
// `finish`.
//
// A match is a correspondence between an old node in the target region
// and a new node in the source region that will replace it. Based on
// this correspondence, the target node is preserved and the attributes
// and children of the source node are copied over it. The `match`
// method declares such a correspondence. A Patcher that makes no matches,
// for example, just removes the target nodes and inserts the source nodes
// in their place.
//
// Constructor:
Spark._Patcher = function(tgtParent, srcParent, tgtBefore, tgtAfter) {
this.tgtParent = tgtParent;
this.srcParent = srcParent;
this.tgtBefore = tgtBefore;
this.tgtAfter = tgtAfter;
this.lastKeptTgtNode = null;
this.lastKeptSrcNode = null;
};
// Advances the patching process up to tgtNode in the target tree,
// and srcNode in the source tree. tgtNode will be preserved, with
// the attributes of srcNode copied over it, in essence identifying
// the two nodes with each other. The same treatment is given to
// any parents of the nodes that are newly implicated as corresponding.
// In the process of traversing from the last matched nodes to these
// ones, all nodes "in between" in the target document, at any level,
// are removed, and all nodes "in between" in the source document
// are copied over to their appropriate positions.
//
// For example, if match() is called only once, and then finish()
// is called, the effect is to preserve tgtNode, its children,
// and its ancestors (parent chain), while swapping out all its
// siblings and the siblings of its ancestors, so that the target
// tree is mutated to look like the source tree did.
//
// The caller is responsible for ensuring the precondition that
// subsequent tgtNodes and subsequent srcNodes are strictly "in order."
// The ordering referred to here is a partial order in which A comes
// before B if their tags would be disjoint in HTML, i.e. the end of
// A comes before the beginning of B. Put another way, there is some
// ancestor of A and some ancestor of B that have the same parent,
// are different, and are in order.
//
// There are other requirements for two nodes to be "matched,"
// but match() can detect them and exit gracefully returning false.
// For example, the tag-names must be the same, and the tag-names
// of their parents. More subtly, it may be impossible to match
// the parents of tgtNode or srcNode because they have been
// previously matched. If we are to match a series of P tags
// that are each inside one DIV, for example, is it the same DIV
// or not? If the source and target disagree, we will have to
// reparent one of the Ps. Users should not be moving identified
// nodes, but we want to still be correct (fall back on replacement)
// if they do.
//
// If false is returned, the match was impossible, but patching
// can continue and will still be otherwise correct. The next call
// to match() must still obey the order constraint, as the patcher
// internally only moves forwards and patches as it goes.
//
// copyCallback is called on every new matched (tgt, src) pair
// right after copying attributes. It's a good time to transplant
// liveranges and patch children.
Spark._Patcher.prototype.match = function(
tgtNode, srcNode, copyCallback, onlyAdvance) {
// last nodes "kept" (matched/identified with each other)
var lastKeptTgt = this.lastKeptTgtNode;
var lastKeptSrc = this.lastKeptSrcNode;
// nodes to match and keep, this time around
var tgt = tgtNode;
var src = srcNode;
if ((! tgt) != (! src)) {
return false; // truthinesses don't match
}
var starting = ! lastKeptTgt;
var finishing = ! tgt;
if (! starting) {
// move lastKeptTgt/lastKeptSrc forward and out,
// until they are siblings of tgt/src or of an ancestor of tgt/src,
// replacing as we go. If tgt/src is falsy, we make it to the
// top level.
while (lastKeptTgt.parentNode !== this.tgtParent &&
! (tgt && DomUtils.elementContains(lastKeptTgt.parentNode, tgt))) {
// Last-kept nodes are inside parents that are not
// parents of the newly matched nodes. Must finish
// replacing their contents and back out.
this._replaceNodes(lastKeptTgt, null, lastKeptSrc, null);
lastKeptTgt = lastKeptTgt.parentNode;
lastKeptSrc = lastKeptSrc.parentNode;
}
// update instance vars; there's no going back inside these nodes
this.lastKeptTgtNode = lastKeptTgt;
this.lastKeptSrcNode = lastKeptSrc;
// Make sure same number of levels of "moving up" are
// appropriate for src as well, i.e. we aren't trying
// to match <c> in (<a><b/><c/></a>, <a><b/></a><a><c/></a>)
// after matching <b>, or vice versa. In other words,
// if tag names and depths match, but identities of parents
// are inconsistent relative to previous matches, we catch it
// here. In the example, lastKeptTgt would be the <b/> node
// on the left, which is not sibling of <c/> or of an ancestor
// of <c/> on the right. If the example were reversed,
// lastKeptTgt would be the first <a> node, which is an
// ancestor of <c/> on the left rather than a sibling of an
// ancestor.
if (! finishing &&
(DomUtils.elementContains(lastKeptSrc, src) ||
! (lastKeptSrc.parentNode === this.srcParent ||
DomUtils.elementContains(lastKeptSrc.parentNode, src)))) {
return false;
}
}
if (finishing) {
this._replaceNodes(lastKeptTgt, null, lastKeptSrc, null,
this.tgtParent, this.srcParent);
} else {
// Compare tag names and depths to make sure we can match nodes...
if (! onlyAdvance) {
if (tgt.nodeName !== src.nodeName)
return false;
}
// Look at tags of parents until we hit parent of last-kept,
// which we know is ok.
for(var a=tgt.parentNode, b=src.parentNode;
a !== (starting ? this.tgtParent : lastKeptTgt.parentNode);
a = a.parentNode, b = b.parentNode) {
if (b === (starting ? this.srcParent : lastKeptSrc.parentNode))
return false; // src is shallower, b hit top first
if (a.nodeName !== b.nodeName)
return false; // tag names don't match
}
if (b !== (starting ? this.srcParent : lastKeptSrc.parentNode)) {
return false; // src is deeper, b didn't hit top when a did
}
var firstIter = true;
// move tgt and src backwards and out, replacing as we go
while (true) {
if (! (firstIter && onlyAdvance)) {
Spark._Patcher._copyAttributes(tgt, src);
if (copyCallback)
copyCallback(tgt, src);
}
firstIter = false;
if ((starting ? this.tgtParent : lastKeptTgt.parentNode)
=== tgt.parentNode) {
// we've worked our way up to the same level as the last-kept nodes
this._replaceNodes(lastKeptTgt, tgt, lastKeptSrc, src);
break;
} else {
this._replaceNodes(null, tgt, null, src);
// move up to keep (match) parents as well
tgt = tgt.parentNode;
src = src.parentNode;
}
}
}
this.lastKeptTgtNode = tgtNode;
this.lastKeptSrcNode = srcNode;
return true;
};
// After a match, skip ahead to later siblings of the last kept nodes,
// without performing any replacements.
Spark._Patcher.prototype.skipToSiblings = function(tgt, src) {
var lastTgt = this.lastKeptTgtNode;
var lastSrc = this.lastKeptSrcNode;
if (! (lastTgt && lastTgt.parentNode === tgt.parentNode))
return false;
if (! (lastSrc && lastSrc.parentNode === src.parentNode))
return false;
this.lastKeptTgtNode = tgt;
this.lastKeptSrcNode = src;
return true;
};
// Completes patching assuming no more matches.
//
// Patchers are single-use, so no more methods can be called
// on the Patcher.
Spark._Patcher.prototype.finish = function() {
return this.match(null, null);
};
// Replaces the siblings between tgtBefore and tgtAfter (exclusive on both
// sides) with the siblings between srcBefore and srcAfter (exclusive on both
// sides). Falsy values indicate start or end of siblings as appropriate.
//
// Precondition: tgtBefore and tgtAfter have same parent; either may be falsy,
// but not both, unless optTgtParent is provided. Same with srcBefore/srcAfter.
Spark._Patcher.prototype._replaceNodes = function(
tgtBefore, tgtAfter, srcBefore, srcAfter, optTgtParent, optSrcParent)
{
var tgtParent = optTgtParent || (tgtBefore || tgtAfter).parentNode;
var srcParent = optSrcParent || (srcBefore || srcAfter).parentNode;
// deal with case where top level is a range
if (tgtParent === this.tgtParent) {
tgtBefore = tgtBefore || this.tgtBefore;
tgtAfter = tgtAfter || this.tgtAfter;
}
if (srcParent === this.srcParent) {
srcBefore = srcBefore || this.srcBefore;
srcAfter = srcAfter || this.srcAfter;
}
// remove old children
var n;
while ((n = tgtBefore ? tgtBefore.nextSibling : tgtParent.firstChild)
&& n !== tgtAfter) {
tgtParent.removeChild(n);
}
// add new children
var m;
while ((m = srcBefore ? srcBefore.nextSibling : srcParent.firstChild)
&& m !== srcAfter) {
tgtParent.insertBefore(m, tgtAfter || null);
}
};
// Copy HTML attributes of node `src` onto node `tgt`.
//
// The effect we are trying to achieve is best expresed in terms of
// HTML. Whatever HTML generated `tgt`, we want to mutate the DOM element
// so that it is as if it were the HTML that generated `src`.
// We want to preserve JavaScript properties in general (tgt.foo),
// while syncing the HTML attributes (tgt.getAttribute("foo")).
//
// This is complicated by form controls and the fact that old IE
// can't keep the difference straight between properties and attributes.
Spark._Patcher._copyAttributes = function(tgt, src) {
var srcAttrs = src.attributes;
var tgtAttrs = tgt.attributes;
// Determine whether tgt has focus; works in all browsers
// as of FF3, Safari4
var target_focused = (tgt === document.activeElement);
// Is this a control with a user-mutated "value" property?
var has_user_value = (
(tgt.nodeName === "INPUT" &&
(tgt.type === "text")) ||
tgt.nodeName === "TEXTAREA");
///// Clear current attributes
if (tgt.style.cssText)
tgt.style.cssText = '';
var isRadio = false;
if (tgt.nodeName === "INPUT") {
// Record for later whether this is a radio button.
isRadio = (tgt.type === 'radio');
// Clearing the attributes of a checkbox won't necessarily
// uncheck it, eg in FF12, so we uncheck explicitly.
if (typeof tgt.checked === "boolean")
tgt.checked = false;
}
for(var i=tgtAttrs.length-1; i>=0; i--) {
var attr = tgtAttrs[i];
// In old IE, attributes that are possible on a node
// but not actually present will show up in this loop
// with specified=false. All other browsers support
// 'specified' (because it's part of the spec) and
// set it to true.
if (! attr.specified)
continue;
var name = attr.name;
// Filter out attributes that are indexable by number
// but not by name. This kills the weird "propdescname"
// attribute in IE 8.
if (! tgtAttrs[name])
continue;
// Some properties don't mutate well, and we simply
// don't try to patch them. For example, you can't
// change a control's type in IE.
if (name === "id" || name === "type")
continue;
// Removing a radio button's "name" property and restoring
// it is harmless in most browsers but breaks in IE 7.
// It seems unlikely enough that a radio button will
// sometimes have a group and sometimes not.
if (isRadio && name === "name")
continue;
// Never delete the "value" attribute. It's more effective
// to simply overwrite it in the next phase.
if (name === "value")
continue;
// Removing 'src' (e.g. in an iframe) can only be bad.
if (name === "src")
continue;
// We want to patch any HTML attributes that were specified in the
// source, but preserve DOM properties set programmatically.
// Old IE makes this difficult by exposing properties as attributes.
// Expando properties will even appear in innerHTML, though not if the
// value is an object rather than a primitive.
//
// We use a heuristic to determine if we are looking at a programmatic
// property (an expando) rather than a DOM attribute.
//
// Losing jQuery's expando (whose value is a number) is very bad,
// because it points to event handlers that only jQuery can detach,
// and only if the expando is in place.
var possibleExpando = tgt[name];
if (possibleExpando &&
(typeof possibleExpando === "object" ||
/^jQuery/.test(name)))
continue; // for object properties that surface attributes only in IE
tgt.removeAttributeNode(attr);
}
///// Copy over src's attributes
if (tgt.mergeAttributes) {
// IE code path:
//
// Only IE (all versions) has mergeAttributes.
// It's probably a good bit faster in old IE than
// iterating over all the attributes, and the treatment
// of form controls is sufficiently different in IE from
// other browsers that we keep the special cases separate.
tgt.mergeAttributes(src);
if (typeof tgt.checked !== "undefined" ||
typeof src.checked !== "undefined")
tgt.checked = src.checked;
if (src.name)
tgt.name = src.name;
} else {
// Non-IE code path:
for(var i=0, L=srcAttrs.length; i<L; i++) {
var srcA = srcAttrs.item(i);
if (srcA.specified) {
var name = srcA.name.toLowerCase();
var value = String(srcA.value);
if (name === "type") {
// can't change type of INPUT in IE; don't support it
} else if (name === "checked") {
tgt.checked = tgt.defaultChecked = (value && value !== "false");
tgt.setAttribute("checked", "checked");
} else if (name === "style") {
tgt.style.cssText = src.style.cssText;
} else if (name === "class") {
tgt.className = src.className;
} else if (name === "value") {
// don't set attribute, just overwrite property
// (in next phase)
} else if (name === "src") {
// only set if different. protects iframes
if (src.src !== tgt.src)
tgt.src = src.src;
} else {
tgt.setAttribute(name, value);
}
}
}
}
// Copy the control's value, only if tgt doesn't have focus.
if (has_user_value) {
if (! target_focused)
tgt.value = src.value;
}
};

View File

@@ -440,8 +440,7 @@ Spark.isolate = function (htmlFunc) {
});
tempRange.destroy();
var oldContents = replaceContentsPreservingLandmarks(range, frag);
Spark.finalize(oldContents);
replaceContentsPreservingLandmarks(range, frag);
range.destroy();
});
});
@@ -466,10 +465,10 @@ Spark.createLandmark = withRenderer(function (options, html, _renderer) {
preserve[selector] = true;
});
else
preserve = options.preserve;
preserve = options.preserve || {};
for (var selector in preserve)
if (typeof preserve[selector] !== 'function')
preserve[selector] = function () { return true; }
preserve[selector] = function () { return true; };
return _renderer.annotate(
html, Spark._ANNOTATION_LANDMARK, {
@@ -547,9 +546,43 @@ var visitMatchingLandmarks = function (range1, range2, func) {
// {type: "region", fromStart: Node, fromEnd: Node,
// toStart: Node, toEnd: Node}
var computePreservations = function (oldRange, newRange) {
var preservations = [];
visitMatchingLandmarks(oldRange, newRange, function (from, to) {
// XXX
if (to.constant)
preservations.push({
type: "region",
fromStart: from.firstNode(), fromEnd: from.lastNode(),
newRange: newRange
});
_.each(to.preserve, function (nodeLabeler, selector) {
var fromNodesByLabel = {}; // label -> node
var visitLabeledNodes = function (range, func) {
var nodes = DomUtils.findAllInRange(
range.firstNode(), range.lastNode(), selector);
_.each(nodes, function (n) {
var label = nodeLabeler(n);
label && func(n, label);
});
};
visitLabeledNodes(from, function (n, label) {
fromNodesByLabel[label] = n;
});
visitLabeledNodes(to, function (n, label) {
var match = fromNodesByLabel[label];
if (match) {
preservations.push({ type: "node", from: match, to: n });
fromNodesByLabel[label] = null;
}
});
});
});
return preservations;
};
// Look for landmarks in oldRange that match landmarks in
@@ -575,7 +608,7 @@ var moveLandmarks = function (oldRange, newRange) {
});
};
// Replace the contents of `range` with the fragment `frag`. Return
// Replace the contents of `range` with the fragment `frag`. Finalize
// the old contents of `range`. If the old contents had any landmarks
// that match landmarks in `frag`, move the landmarks over and perform
// any node or region preservations that they request.
@@ -585,8 +618,15 @@ var replaceContentsPreservingLandmarks = function (range, frag) {
moveLandmarks(range, tempRange);
tempRange.destroy();
// XXX should patch (using preservations)
return range.replace_contents(frag);
// patch (using preservations)
range.operate(function (start, end) {
// XXX this will destroy all liveranges, including ones
// inside constant regions whose DOM nodes we are going
// to preserve untouched
Spark.finalize(start, end);
Spark._patch(start.parentNode, frag, start.previousSibling,
end.nextSibling, preservations);
});
};
// Find all the landmarks in `range` and let them know that they are

View File

@@ -1158,4 +1158,184 @@ Tinytest.add("spark - labeled landmarks", function (test) {
});
var legacyLabels = {
'*[id], #[name]': function(n) {
var label = null;
if (n.nodeType === 1) {
if (n.id) {
label = '#'+n.id;
} else if (n.getAttribute("name")) {
label = n.getAttribute("name");
// Radio button special case: radio buttons
// in a group all have the same name. Their value
// determines their identity.
// Checkboxes with the same name and different
// values are also sometimes used in apps, so
// we treat them similarly.
if (n.nodeName === 'INPUT' &&
(n.type === 'radio' || n.type === 'checkbox') &&
n.value)
label = label + ':' + n.value;
}
}
return label;
}
};
Tinytest.add("spark - preserved nodes (diff/patch)", function(test) {
var rand;
var randomNodeList = function(optParentTag, depth) {
var atTopLevel = ! optParentTag;
var len = rand.nextIntBetween(atTopLevel ? 1 : 0, 6);
var buf = [];
for(var i=0; i<len; i++)
buf.push(randomNode(optParentTag, depth));
return buf;
};
var randomNode = function(optParentTag, depth) {
var n = {};
if (rand.nextBoolean()) {
// text node
n.text = rand.nextIdentifier(2);
} else {
n.tagName = rand.nextChoice((function() {
switch (optParentTag) {
case "p": return ['b', 'i', 'u'];
case "b": return ['i', 'u'];
case "i": return ['u'];
case "u": case "span": return ['span'];
default: return ['div', 'ins', 'center', 'p'];
}
})());
if (rand.nextBoolean())
n.id = rand.nextIdentifier();
if (rand.nextBoolean())
n.name = rand.nextIdentifier();
if (depth === 0) {
n.children = [];
} else {
n.children = randomNodeList(n.tagName, depth-1);
}
}
var existence = rand.nextChoice([[true, true], [false, true], [true, false]]);
n.existsBefore = existence[0];
n.existsAfter = existence[1];
return n;
};
var nodeListToHtml = function(list, is_after, optBuf) {
var buf = (optBuf || []);
_.each(list, function(n) {
if (is_after ? n.existsAfter : n.existsBefore) {
if (n.text) {
buf.push(n.text);
} else {
buf.push('<', n.tagName);
if (n.id)
buf.push(' id="', n.id, '"');
if (n.name)
buf.push(' name="', n.name, '"');
buf.push('>');
nodeListToHtml(n.children, is_after, buf);
buf.push('</', n.tagName, '>');
}
}
});
return optBuf ? null : buf.join('');
};
var fillInElementIdentities = function(list, parent, is_after) {
var elementsInList = _.filter(
list,
function(x) {
return (is_after ? x.existsAfter : x.existsBefore) && x.tagName;
});
var elementsInDom = _.filter(parent.childNodes,
function(x) { return x.nodeType === 1; });
test.equal(elementsInList.length, elementsInDom.length);
for(var i=0; i<elementsInList.length; i++) {
elementsInList[i].node = elementsInDom[i];
fillInElementIdentities(elementsInList[i].children,
elementsInDom[i]);
}
};
var getParentChain = function(node) {
var buf = [];
while (node) {
buf.push(node);
node = node.parentNode;
}
return buf;
};
var isSameElements = function(a, b) {
if (a.length !== b.length)
return false;
for(var i=0; i<a.length; i++) {
if (a[i] !== b[i])
return false;
}
return true;
};
var collectLabeledNodeData = function(list, optArray) {
var buf = optArray || [];
_.each(list, function(x) {
if (x.tagName && x.existsBefore && x.existsAfter) {
if (x.name || x.id) {
buf.push({ node: x.node, parents: getParentChain(x.node) });
}
collectLabeledNodeData(x.children, buf);
}
});
return buf;
};
for(var i=0; i<5; i++) {
// Use non-deterministic randomness so we can have a shorter fuzz
// test (fewer iterations). For deterministic (fully seeded)
// randomness, remove the call to Math.random().
rand = new SeededRandom("preserved nodes "+i+" "+Math.random());
var R = ReactiveVar(false);
var structure = randomNodeList(null, 6);
var frag = WrappedFrag(Meteor.render(function() {
return Spark.createLandmark(
{preserve: legacyLabels},
nodeListToHtml(structure, R.get()));
})).hold();
test.equal(frag.html(), nodeListToHtml(structure, false) || "<!---->");
fillInElementIdentities(structure, frag.node());
var labeledNodes = collectLabeledNodeData(structure);
R.set(true);
Meteor.flush();
test.equal(frag.html(), nodeListToHtml(structure, true) || "<!---->");
_.each(labeledNodes, function(x) {
test.isTrue(isSameElements(x.parents, getParentChain(x.node)));
});
frag.release();
Meteor.flush();
test.equal(R.numListeners(), 0);
}
});
})();