diff --git a/logic.js b/logic.js index 6b827df..ae77edb 100644 --- a/logic.js +++ b/logic.js @@ -36,429 +36,439 @@ http://ricostacruz.com/cheatsheets/umdjs.html return a; } - var jsonLogic = {}; - var operations = { - "==": function(a, b) { - return a == b; - }, - "===": function(a, b) { - return a === b; - }, - "!=": function(a, b) { - return a != b; - }, - "!==": function(a, b) { - return a !== b; - }, - ">": function(a, b) { - return a > b; - }, - ">=": function(a, b) { - return a >= b; - }, - "<": function(a, b, c) { - return (c === undefined) ? a < b : (a < b) && (b < c); - }, - "<=": function(a, b, c) { - return (c === undefined) ? a <= b : (a <= b) && (b <= c); - }, - "!!": function(a) { - return jsonLogic.truthy(a); - }, - "!": function(a) { - return !jsonLogic.truthy(a); - }, - "%": function(a, b) { - return a % b; - }, - "log": function(a) { - console.log(a); return a; - }, - "in": function(a, b) { - if(!b || typeof b.indexOf === "undefined") return false; - return (b.indexOf(a) !== -1); - }, - "cat": function() { - return Array.prototype.join.call(arguments, ""); - }, - "substr":function(source, start, end) { - if(end < 0){ - // JavaScript doesn't support negative end, this emulates PHP behavior - var temp = String(source).substr(start); - return temp.substr(0, temp.length + end); - } - return String(source).substr(start, end); - }, - "+": function() { - return Array.prototype.reduce.call(arguments, function(a, b) { - return parseFloat(a, 10) + parseFloat(b, 10); - }, 0); - }, - "*": function() { - return Array.prototype.reduce.call(arguments, function(a, b) { - return parseFloat(a, 10) * parseFloat(b, 10); - }); - }, - "-": function(a, b) { - if(b === undefined) { - return -a; - }else{ - return a - b; - } - }, - "/": function(a, b) { - return a / b; - }, - "min": function() { - return Math.min.apply(this, arguments); - }, - "max": function() { - return Math.max.apply(this, arguments); - }, - "merge": function() { - return Array.prototype.reduce.call(arguments, function(a, b) { - return a.concat(b); - }, []); - }, - "var": function(a, b) { - var not_found = (b === undefined) ? null : b; - var data = this; - if(typeof a === "undefined" || a==="" || a===null) { - return data; - } - var sub_props = String(a).split("."); - for(var i = 0; i < sub_props.length; i++) { - if(data === null) { - return not_found; + function JSONLogic() { + var jsonLogic = {}; + var operations = { + "==": function(a, b) { + return a == b; + }, + "===": function(a, b) { + return a === b; + }, + "!=": function(a, b) { + return a != b; + }, + "!==": function(a, b) { + return a !== b; + }, + ">": function(a, b) { + return a > b; + }, + ">=": function(a, b) { + return a >= b; + }, + "<": function(a, b, c) { + return (c === undefined) ? a < b : (a < b) && (b < c); + }, + "<=": function(a, b, c) { + return (c === undefined) ? a <= b : (a <= b) && (b <= c); + }, + "!!": function(a) { + return jsonLogic.truthy(a); + }, + "!": function(a) { + return !jsonLogic.truthy(a); + }, + "%": function(a, b) { + return a % b; + }, + "log": function(a) { + console.log(a); return a; + }, + "in": function(a, b) { + if(!b || typeof b.indexOf === "undefined") return false; + return (b.indexOf(a) !== -1); + }, + "cat": function() { + return Array.prototype.join.call(arguments, ""); + }, + "substr":function(source, start, end) { + if(end < 0){ + // JavaScript doesn't support negative end, this emulates PHP behavior + var temp = String(source).substr(start); + return temp.substr(0, temp.length + end); } - // Descending into data - data = data[sub_props[i]]; - if(data === undefined) { - return not_found; + return String(source).substr(start, end); + }, + "+": function() { + return Array.prototype.reduce.call(arguments, function(a, b) { + return parseFloat(a, 10) + parseFloat(b, 10); + }, 0); + }, + "*": function() { + return Array.prototype.reduce.call(arguments, function(a, b) { + return parseFloat(a, 10) * parseFloat(b, 10); + }); + }, + "-": function(a, b) { + if(b === undefined) { + return -a; + }else{ + return a - b; } - } - return data; - }, - "missing": function() { - /* - Missing can receive many keys as many arguments, like {"missing:[1,2]} - Missing can also receive *one* argument that is an array of keys, - which typically happens if it's actually acting on the output of another command - (like 'if' or 'merge') - */ - - var missing = []; - var keys = Array.isArray(arguments[0]) ? arguments[0] : arguments; - - for(var i = 0; i < keys.length; i++) { - var key = keys[i]; - var value = jsonLogic.apply({"var": key}, this); - if(value === null || value === "") { - missing.push(key); + }, + "/": function(a, b) { + return a / b; + }, + "min": function() { + return Math.min.apply(this, arguments); + }, + "max": function() { + return Math.max.apply(this, arguments); + }, + "merge": function() { + return Array.prototype.reduce.call(arguments, function(a, b) { + return a.concat(b); + }, []); + }, + "var": function(a, b) { + var not_found = (b === undefined) ? null : b; + var data = this; + if(typeof a === "undefined" || a==="" || a===null) { + return data; } - } + var sub_props = String(a).split("."); + for(var i = 0; i < sub_props.length; i++) { + if(data === null) { + return not_found; + } + // Descending into data + data = data[sub_props[i]]; + if(data === undefined) { + return not_found; + } + } + return data; + }, + "missing": function() { + /* + Missing can receive many keys as many arguments, like {"missing:[1,2]} + Missing can also receive *one* argument that is an array of keys, + which typically happens if it's actually acting on the output of another command + (like 'if' or 'merge') + */ - return missing; - }, - "missing_some": function(need_count, options) { - // missing_some takes two arguments, how many (minimum) items must be present, and an array of keys (just like 'missing') to check for presence. - var are_missing = jsonLogic.apply({"missing": options}, this); + var missing = []; + var keys = Array.isArray(arguments[0]) ? arguments[0] : arguments; - if(options.length - are_missing.length >= need_count) { - return []; - }else{ - return are_missing; + for(var i = 0; i < keys.length; i++) { + var key = keys[i]; + var value = jsonLogic.apply({"var": key}, this); + if(value === null || value === "") { + missing.push(key); + } + } + + return missing; + }, + "missing_some": function(need_count, options) { + // missing_some takes two arguments, how many (minimum) items must be present, and an array of keys (just like 'missing') to check for presence. + var are_missing = jsonLogic.apply({"missing": options}, this); + + if(options.length - are_missing.length >= need_count) { + return []; + }else{ + return are_missing; + } + }, + "method": function(obj, method, args) { + return obj[method].apply(obj, args); + }, + + }; + + jsonLogic.is_logic = function(logic) { + return ( + typeof logic === "object" && // An object + logic !== null && // but not null + ! Array.isArray(logic) && // and not an array + Object.keys(logic).length === 1 // with exactly one key + ); + }; + + /* + This helper will defer to the JsonLogic spec as a tie-breaker when different language interpreters define different behavior for the truthiness of primitives. E.g., PHP considers empty arrays to be falsy, but Javascript considers them to be truthy. JsonLogic, as an ecosystem, needs one consistent answer. + + Spec and rationale here: http://jsonlogic.com/truthy + */ + jsonLogic.truthy = function(value) { + if(Array.isArray(value) && value.length === 0) { + return false; } - }, - "method": function(obj, method, args) { - return obj[method].apply(obj, args); - }, - - }; - - jsonLogic.is_logic = function(logic) { - return ( - typeof logic === "object" && // An object - logic !== null && // but not null - ! Array.isArray(logic) && // and not an array - Object.keys(logic).length === 1 // with exactly one key - ); - }; - - /* - This helper will defer to the JsonLogic spec as a tie-breaker when different language interpreters define different behavior for the truthiness of primitives. E.g., PHP considers empty arrays to be falsy, but Javascript considers them to be truthy. JsonLogic, as an ecosystem, needs one consistent answer. - - Spec and rationale here: http://jsonlogic.com/truthy - */ - jsonLogic.truthy = function(value) { - if(Array.isArray(value) && value.length === 0) { - return false; - } - return !! value; - }; + return !! value; + }; - jsonLogic.get_operator = function(logic) { - return Object.keys(logic)[0]; - }; + jsonLogic.get_operator = function(logic) { + return Object.keys(logic)[0]; + }; - jsonLogic.get_values = function(logic) { - return logic[jsonLogic.get_operator(logic)]; - }; + jsonLogic.get_values = function(logic) { + return logic[jsonLogic.get_operator(logic)]; + }; - jsonLogic.apply = function(logic, data) { - // Does this array contain logic? Only one way to find out. - if(Array.isArray(logic)) { - return logic.map(function(l) { - return jsonLogic.apply(l, data); - }); - } - // You've recursed to a primitive, stop! - if( ! jsonLogic.is_logic(logic) ) { - return logic; - } + jsonLogic.apply = function(logic, data) { + // Does this array contain logic? Only one way to find out. + if(Array.isArray(logic)) { + return logic.map(function(l) { + return jsonLogic.apply(l, data); + }); + } + // You've recursed to a primitive, stop! + if( ! jsonLogic.is_logic(logic) ) { + return logic; + } - data = data || {}; + data = data || {}; - var op = jsonLogic.get_operator(logic); - var values = logic[op]; - var i; - var current; - var scopedLogic, scopedData, filtered, initial; + var op = jsonLogic.get_operator(logic); + var values = logic[op]; + var i; + var current; + var scopedLogic, scopedData, filtered, initial; - // easy syntax for unary operators, like {"var" : "x"} instead of strict {"var" : ["x"]} - if( ! Array.isArray(values)) { - values = [values]; - } + // easy syntax for unary operators, like {"var" : "x"} instead of strict {"var" : ["x"]} + if( ! Array.isArray(values)) { + values = [values]; + } - // 'if', 'and', and 'or' violate the normal rule of depth-first calculating consequents, let each manage recursion as needed. - if(op === "if" || op == "?:") { - /* 'if' should be called with a odd number of parameters, 3 or greater - This works on the pattern: - if( 0 ){ 1 }else{ 2 }; - if( 0 ){ 1 }else if( 2 ){ 3 }else{ 4 }; - if( 0 ){ 1 }else if( 2 ){ 3 }else if( 4 ){ 5 }else{ 6 }; - - The implementation is: - For pairs of values (0,1 then 2,3 then 4,5 etc) - If the first evaluates truthy, evaluate and return the second - If the first evaluates falsy, jump to the next pair (e.g, 0,1 to 2,3) - given one parameter, evaluate and return it. (it's an Else and all the If/ElseIf were false) - given 0 parameters, return NULL (not great practice, but there was no Else) - */ - for(i = 0; i < values.length - 1; i += 2) { - if( jsonLogic.truthy( jsonLogic.apply(values[i], data) ) ) { - return jsonLogic.apply(values[i+1], data); + // 'if', 'and', and 'or' violate the normal rule of depth-first calculating consequents, let each manage recursion as needed. + if(op === "if" || op == "?:") { + /* 'if' should be called with a odd number of parameters, 3 or greater + This works on the pattern: + if( 0 ){ 1 }else{ 2 }; + if( 0 ){ 1 }else if( 2 ){ 3 }else{ 4 }; + if( 0 ){ 1 }else if( 2 ){ 3 }else if( 4 ){ 5 }else{ 6 }; + + The implementation is: + For pairs of values (0,1 then 2,3 then 4,5 etc) + If the first evaluates truthy, evaluate and return the second + If the first evaluates falsy, jump to the next pair (e.g, 0,1 to 2,3) + given one parameter, evaluate and return it. (it's an Else and all the If/ElseIf were false) + given 0 parameters, return NULL (not great practice, but there was no Else) + */ + for(i = 0; i < values.length - 1; i += 2) { + if( jsonLogic.truthy( jsonLogic.apply(values[i], data) ) ) { + return jsonLogic.apply(values[i+1], data); + } } - } - if(values.length === i+1) return jsonLogic.apply(values[i], data); - return null; - }else if(op === "and") { // Return first falsy, or last - for(i=0; i < values.length; i+=1) { - current = jsonLogic.apply(values[i], data); - if( ! jsonLogic.truthy(current)) { - return current; + if(values.length === i+1) return jsonLogic.apply(values[i], data); + return null; + }else if(op === "and") { // Return first falsy, or last + for(i=0; i < values.length; i+=1) { + current = jsonLogic.apply(values[i], data); + if( ! jsonLogic.truthy(current)) { + return current; + } } - } - return current; // Last - }else if(op === "or") {// Return first truthy, or last - for(i=0; i < values.length; i+=1) { - current = jsonLogic.apply(values[i], data); - if( jsonLogic.truthy(current) ) { - return current; + return current; // Last + }else if(op === "or") {// Return first truthy, or last + for(i=0; i < values.length; i+=1) { + current = jsonLogic.apply(values[i], data); + if( jsonLogic.truthy(current) ) { + return current; + } } - } - return current; // Last + return current; // Last - }else if(op === 'filter'){ - scopedData = jsonLogic.apply(values[0], data); - scopedLogic = values[1]; + }else if(op === 'filter'){ + scopedData = jsonLogic.apply(values[0], data); + scopedLogic = values[1]; - if ( ! Array.isArray(scopedData)) { - return []; - } - // Return only the elements from the array in the first argument, - // that return truthy when passed to the logic in the second argument. - // For parity with JavaScript, reindex the returned array - return scopedData.filter(function(datum){ - return jsonLogic.truthy( jsonLogic.apply(scopedLogic, datum)); - }); - }else if(op === 'map'){ - scopedData = jsonLogic.apply(values[0], data); - scopedLogic = values[1]; + if ( ! Array.isArray(scopedData)) { + return []; + } + // Return only the elements from the array in the first argument, + // that return truthy when passed to the logic in the second argument. + // For parity with JavaScript, reindex the returned array + return scopedData.filter(function(datum){ + return jsonLogic.truthy( jsonLogic.apply(scopedLogic, datum)); + }); + }else if(op === 'map'){ + scopedData = jsonLogic.apply(values[0], data); + scopedLogic = values[1]; - if ( ! Array.isArray(scopedData)) { - return []; + if ( ! Array.isArray(scopedData)) { + return []; + } + + return scopedData.map(function(datum){ + return jsonLogic.apply(scopedLogic, datum); + }); + + }else if(op === 'reduce'){ + scopedData = jsonLogic.apply(values[0], data); + scopedLogic = values[1]; + initial = typeof values[2] !== 'undefined' ? values[2] : null; + + if ( ! Array.isArray(scopedData)) { + return initial; + } + + return scopedData.reduce( + function(accumulator, current){ + return jsonLogic.apply( + scopedLogic, + {'current':current, 'accumulator':accumulator} + ); + }, + initial + ); + + }else if(op === "all") { + scopedData = jsonLogic.apply(values[0], data); + scopedLogic = values[1]; + // All of an empty set is false. Note, some and none have correct fallback after the for loop + if( ! scopedData.length) { + return false; + } + for(i=0; i < scopedData.length; i+=1) { + if( ! jsonLogic.truthy( jsonLogic.apply(scopedLogic, scopedData[i]) )) { + return false; // First falsy, short circuit + } + } + return true; // All were truthy + }else if(op === "none") { + filtered = jsonLogic.apply({'filter' : values}, data); + return filtered.length === 0; + + }else if(op === "some") { + filtered = jsonLogic.apply({'filter' : values}, data); + return filtered.length > 0; } - return scopedData.map(function(datum){ - return jsonLogic.apply(scopedLogic, datum); + // Everyone else gets immediate depth-first recursion + values = values.map(function(val) { + return jsonLogic.apply(val, data); }); - }else if(op === 'reduce'){ - scopedData = jsonLogic.apply(values[0], data); - scopedLogic = values[1]; - initial = typeof values[2] !== 'undefined' ? values[2] : null; - if ( ! Array.isArray(scopedData)) { - return initial; + // The operation is called with "data" bound to its "this" and "values" passed as arguments. + // Structured commands like % or > can name formal arguments while flexible commands (like missing or merge) can operate on the pseudo-array arguments + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/arguments + if(typeof operations[op] === "function") { + return operations[op].apply(data, values); + }else if(op.indexOf(".") > 0) { // Contains a dot, and not in the 0th position + var sub_ops = String(op).split("."); + var operation = operations; + for(i = 0; i < sub_ops.length; i++) { + // Descending into operations + operation = operation[sub_ops[i]]; + if(operation === undefined) { + throw new Error("Unrecognized operation " + op + + " (failed at " + sub_ops.slice(0, i+1).join(".") + ")"); + } + } + + return operation.apply(data, values); } - return scopedData.reduce( - function(accumulator, current){ - return jsonLogic.apply( - scopedLogic, - {'current':current, 'accumulator':accumulator} - ); - }, - initial - ); + throw new Error("Unrecognized operation " + op ); + }; - }else if(op === "all") { - scopedData = jsonLogic.apply(values[0], data); - scopedLogic = values[1]; - // All of an empty set is false. Note, some and none have correct fallback after the for loop - if( ! scopedData.length) { - return false; - } - for(i=0; i < scopedData.length; i+=1) { - if( ! jsonLogic.truthy( jsonLogic.apply(scopedLogic, scopedData[i]) )) { - return false; // First falsy, short circuit + jsonLogic.uses_data = function(logic) { + var collection = []; + + if( jsonLogic.is_logic(logic) ) { + var op = jsonLogic.get_operator(logic); + var values = logic[op]; + + if( ! Array.isArray(values)) { + values = [values]; } - } - return true; // All were truthy - }else if(op === "none") { - filtered = jsonLogic.apply({'filter' : values}, data); - return filtered.length === 0; - - }else if(op === "some") { - filtered = jsonLogic.apply({'filter' : values}, data); - return filtered.length > 0; - } - // Everyone else gets immediate depth-first recursion - values = values.map(function(val) { - return jsonLogic.apply(val, data); - }); - - - // The operation is called with "data" bound to its "this" and "values" passed as arguments. - // Structured commands like % or > can name formal arguments while flexible commands (like missing or merge) can operate on the pseudo-array arguments - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/arguments - if(typeof operations[op] === "function") { - return operations[op].apply(data, values); - }else if(op.indexOf(".") > 0) { // Contains a dot, and not in the 0th position - var sub_ops = String(op).split("."); - var operation = operations; - for(i = 0; i < sub_ops.length; i++) { - // Descending into operations - operation = operation[sub_ops[i]]; - if(operation === undefined) { - throw new Error("Unrecognized operation " + op + - " (failed at " + sub_ops.slice(0, i+1).join(".") + ")"); + if(op === "var") { + // This doesn't cover the case where the arg to var is itself a rule. + collection.push(values[0]); + }else{ + // Recursion! + values.map(function(val) { + collection.push.apply(collection, jsonLogic.uses_data(val) ); + }); } } - return operation.apply(data, values); - } - - throw new Error("Unrecognized operation " + op ); - }; + return arrayUnique(collection); + }; - jsonLogic.uses_data = function(logic) { - var collection = []; + jsonLogic.add_operation = function(name, code) { + operations[name] = code; + }; - if( jsonLogic.is_logic(logic) ) { - var op = jsonLogic.get_operator(logic); - var values = logic[op]; + jsonLogic.rm_operation = function(name) { + delete operations[name]; + }; - if( ! Array.isArray(values)) { - values = [values]; + jsonLogic.rule_like = function(rule, pattern) { + // console.log("Is ". JSON.stringify(rule) . " like " . JSON.stringify(pattern) . "?"); + if(pattern === rule) { + return true; + } // TODO : Deep object equivalency? + if(pattern === "@") { + return true; + } // Wildcard! + if(pattern === "number") { + return (typeof rule === "number"); } - - if(op === "var") { - // This doesn't cover the case where the arg to var is itself a rule. - collection.push(values[0]); - }else{ - // Recursion! - values.map(function(val) { - collection.push.apply(collection, jsonLogic.uses_data(val) ); - }); + if(pattern === "string") { + return (typeof rule === "string"); + } + if(pattern === "array") { + // !logic test might be superfluous in JavaScript + return Array.isArray(rule) && ! jsonLogic.is_logic(rule); } - } - - return arrayUnique(collection); - }; - - jsonLogic.add_operation = function(name, code) { - operations[name] = code; - }; - - jsonLogic.rm_operation = function(name) { - delete operations[name]; - }; - - jsonLogic.rule_like = function(rule, pattern) { - // console.log("Is ". JSON.stringify(rule) . " like " . JSON.stringify(pattern) . "?"); - if(pattern === rule) { - return true; - } // TODO : Deep object equivalency? - if(pattern === "@") { - return true; - } // Wildcard! - if(pattern === "number") { - return (typeof rule === "number"); - } - if(pattern === "string") { - return (typeof rule === "string"); - } - if(pattern === "array") { - // !logic test might be superfluous in JavaScript - return Array.isArray(rule) && ! jsonLogic.is_logic(rule); - } - if(jsonLogic.is_logic(pattern)) { - if(jsonLogic.is_logic(rule)) { - var pattern_op = jsonLogic.get_operator(pattern); - var rule_op = jsonLogic.get_operator(rule); - - if(pattern_op === "@" || pattern_op === rule_op) { - // echo "\nOperators match, go deeper\n"; - return jsonLogic.rule_like( - jsonLogic.get_values(rule, false), - jsonLogic.get_values(pattern, false) - ); + if(jsonLogic.is_logic(pattern)) { + if(jsonLogic.is_logic(rule)) { + var pattern_op = jsonLogic.get_operator(pattern); + var rule_op = jsonLogic.get_operator(rule); + + if(pattern_op === "@" || pattern_op === rule_op) { + // echo "\nOperators match, go deeper\n"; + return jsonLogic.rule_like( + jsonLogic.get_values(rule, false), + jsonLogic.get_values(pattern, false) + ); + } } + return false; // pattern is logic, rule isn't, can't be eq } - return false; // pattern is logic, rule isn't, can't be eq - } - if(Array.isArray(pattern)) { - if(Array.isArray(rule)) { - if(pattern.length !== rule.length) { - return false; - } - /* - Note, array order MATTERS, because we're using this array test logic to consider arguments, where order can matter. (e.g., + is commutative, but '-' or 'if' or 'var' are NOT) - */ - for(var i = 0; i < pattern.length; i += 1) { - // If any fail, we fail - if( ! jsonLogic.rule_like(rule[i], pattern[i])) { + if(Array.isArray(pattern)) { + if(Array.isArray(rule)) { + if(pattern.length !== rule.length) { return false; } + /* + Note, array order MATTERS, because we're using this array test logic to consider arguments, where order can matter. (e.g., + is commutative, but '-' or 'if' or 'var' are NOT) + */ + for(var i = 0; i < pattern.length; i += 1) { + // If any fail, we fail + if( ! jsonLogic.rule_like(rule[i], pattern[i])) { + return false; + } + } + return true; // If they *all* passed, we pass + }else{ + return false; // Pattern is array, rule isn't } - return true; // If they *all* passed, we pass - }else{ - return false; // Pattern is array, rule isn't } - } - // Not logic, not array, not a === match for rule. - return false; - }; + // Not logic, not array, not a === match for rule. + return false; + }; + + return jsonLogic; + } + + + var retval = new JSONLogic(); + + retval.JSONLogic = JSONLogic; + + return retval; - return jsonLogic; })); diff --git a/tests/tests.js b/tests/tests.js index b201bf6..ff5871f 100644 --- a/tests/tests.js +++ b/tests/tests.js @@ -317,3 +317,49 @@ QUnit.test("Control structures don't eval depth-first", function(assert) { jsonLogic.apply({"or": [{"push": [true]}, {"push": [true]}]}); assert.deepEqual(i, [true]); }); + + + + +QUnit.test("Each JSONLogic instance can have its own custom operations", function(assert) { + + // This function is just here to ensure that we're calling + // the JSONLogic instances in exactly the same way each time. + function callOperationOn(instance) { + return instance.apply({"join": [["one", "two"], ","]}); + } + + + var foo = new jsonLogic.JSONLogic(); + foo.add_operation("join", function(ary, glue) { + return ary.join(glue); + }); + + + + var bar = new jsonLogic.JSONLogic(); + bar.add_operation("join", function(ary, glue) { + return "Nope."; + }); + + + assert.equal(callOperationOn(foo), "one,two"); + assert.equal(callOperationOn(bar), "Nope."); + assert.throws(function() { + // This custom operator was never defined within the default + // jsonLogic instance. Therefore, it will throw an exception. + callOperationOn(jsonLogic); + }); + + + bar.rm_operation("join"); + assert.throws(function() { + // This line throws an exception now because we removed the custom + // operation from bar. + callOperationOn(bar); + }); + + // Calling bar.rm_operation(...) didn't somehow magically change foo's state. + assert.equal(callOperationOn(foo), "one,two"); + +});