diff --git a/package.json b/package.json index 661ff4d6..5da8c3b6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "content-type": "1.x.x", "dasherize": "2.0.x", "flat": "^1.2.1", + "immutable": "^3.7.5", "jade": "1.11.x", "lodash": "^3.10.0", "negotiator": "ethanresnick/negotiator#full-parse-access", diff --git a/src/ResourceTypeRegistry.js b/src/ResourceTypeRegistry.js index fe3859fc..1edf28d6 100644 --- a/src/ResourceTypeRegistry.js +++ b/src/ResourceTypeRegistry.js @@ -1,124 +1,132 @@ import merge from "lodash/object/merge"; +import Immutable from "immutable"; +import {pseudoTopSort} from "./util/misc"; /** * A private array of properties that will be used by the class below to - * automatically generate simple getter setters for each property, all - * following same format. Those getters/setters will take the resource type - * whose property is being retrieved/set, and the value to set it to, if any. + * automatically generate simple getters for each property, all following the + * same format. Those getters will take the name of the resource type whose + * property is being retrieved. */ -const autoGetterSetterProps = ["dbAdapter", "beforeSave", "beforeRender", +const autoGetterProps = ["dbAdapter", "beforeSave", "beforeRender", "behaviors", "labelMappers", "defaultIncludes", "info", "parentType"]; /** - * Global defaults for resource descriptions, to be merged into defaults - * provided to the ResourceTypeRegistry, which are in turn merged into defaults - * provided in each resource type descriptions. + * Global defaults for all resource descriptions, to be merged into the + * defaults provided to the ResourceTypeRegistry, which are in turn merged + * into the values provided in each resource type description. */ -const globalResourceDefaults = { +const globalResourceDefaults = Immutable.fromJS({ behaviors: { dasherizeOutput: { enabled: true } } -}; +}); + +const typesKey = Symbol(); /** * To fulfill a JSON API request, you often need to know about all the resources - * in the system--not just the primary resource associated with the type being - * requested. For example, if the request is for a User, you might need to - * include related Projects, so the code handling the users request needs access - * to the Project resource's beforeSave and beforeRender methods. Similarly, it - * would need access to url templates that point at relationships on the Project - * resources. Etc. So we handle this by introducing a ResourceTypeRegistry that - * the Controller can have access to. Each resource type is registered by its - * JSON API type and has a number of properties defining it. + * types in the system--not just the type that is the primary target of the + * request. For example, if the request is for a User (or Users), you might need + * to include related Projects, so the code handling the users request needs + * access to the Project resource's beforeSave and beforeRender methods; its + * url templates; etc. So we handle this by introducing a ResourceTypeRegistry + * that the Controller can have access to. Each resource type is registered by + * its JSON API type and has a number of properties defining it. */ export default class ResourceTypeRegistry { - constructor(typeDescriptions = [], descriptionDefaults = {}) { - this._resourceTypes = {}; - this._descriptionDefaults = merge({}, globalResourceDefaults, descriptionDefaults); - typeDescriptions.forEach((it) => { this.type(it); }); - } - - type(type, description) { - // create a one-argument version that takes the - // type as a key on the description object. - if(typeof type === "object" && typeof description === "undefined") { - description = type; - type = type.type; - delete description.type; + constructor(typeDescriptions = {}, descriptionDefaults = {}) { + this[typesKey] = {}; + descriptionDefaults = globalResourceDefaults.mergeDeep(descriptionDefaults); + + // Sort the types so we can register them in an order that respects their + // parentType. First, we pre-process the typeDescriptions to create edges + // pointing to each node's children (rather than the links we have by + // default, which point to the parent). Then we do an abridged topological + // sort that works in this case. Below, nodes is a list of type names. + // Roots are nodes with no parents. Edges is a map, with each key being the + // name of a starting node A, and the value being a set of node names for + // which there is an edge from A to that node. + const nodes = [], roots = [], edges = {}; + + for(const typeName in typeDescriptions) { + const nodeParentType = typeDescriptions[typeName].parentType; + nodes.push(typeName); + + if(nodeParentType) { + edges[nodeParentType] = edges[nodeParentType] || {}; + edges[nodeParentType][typeName] = true; + } + else { + roots.push(typeName); + } } - if(description) { - this._resourceTypes[type] = {}; - - // Merge description defaults into provided description - description = merge({}, this._descriptionDefaults, description); + const typeRegistrationOrder = pseudoTopSort(nodes, edges, roots); + + // register the types, in order + typeRegistrationOrder.forEach((typeName) => { + const parentType = typeDescriptions[typeName].parentType; + + // defaultIncludes need to be made into an object if they came as an array. + // TODO: Remove support for array format before v3. It's inconsistent. + const thisDescriptionRaw = Immutable.fromJS(typeDescriptions[typeName]); + const thisDescriptionMerged = descriptionDefaults.mergeDeep(thisDescriptionRaw); + + this[typesKey][typeName] = (parentType) ? + // If we have a parentType, we merge in all the parent's fields, + // BUT we then overwrite labelMappers with just the ones directly + // from this description. We don't inherit labelMappers because a + // labelMapper is a kind of filter, and the results of a filter + // on the parent type may not be instances of the subtype. + this[typesKey][parentType].mergeDeep(thisDescriptionRaw) + .set("labelMappers", thisDescriptionRaw.get("labelMappers")) : + + // If we don't have a parentType, just register + // the description merged with the universal defaults + thisDescriptionMerged; + }); + } - // Set all the properties for the type that the description provides. - autoGetterSetterProps.concat(["urlTemplates", "behaviors"]).forEach((k) => { - if(Object.prototype.hasOwnProperty.call(description, k)) { - this[k](type, description[k]); - } - }); - } - else if(this._resourceTypes[type]) { - return Object.assign({}, this._resourceTypes[type]); - } + type(typeName) { + return this.hasType(typeName) ? this[typesKey][typeName].toJS() : undefined; } - types() { - return Object.keys(this._resourceTypes); + hasType(typeName) { + return typeName in this[typesKey]; } - //calling the arg "templatesToSet" to avoid conflict with templates var below - urlTemplates(type, templatesToSet) { - this._resourceTypes[type] = this._resourceTypes[type] || {}; - - switch(arguments.length) { - case 1: - return this._resourceTypes[type].urlTemplates - ? Object.assign({}, this._resourceTypes[type].urlTemplates) - : this._resourceTypes[type].urlTemplates; - - case 0: - let templates = {}; - for(let currType in this._resourceTypes) { - templates[currType] = this.urlTemplates(currType); - } - return templates; - - default: - this._resourceTypes[type].urlTemplates = templatesToSet; - } + typeNames() { + return Object.keys(this[typesKey]); } - behaviors(type, behaviorsToSet) { - this._resourceTypes[type] = this._resourceTypes[type] || {}; - if (behaviorsToSet) { - this._resourceTypes[type].behaviors = - merge({}, this._descriptionDefaults.behaviors, behaviorsToSet); + urlTemplates(type) { + if(type) { + const maybeDesc = this[typesKey][type]; + const maybeTemplates = maybeDesc ? maybeDesc.get('urlTemplates') : maybeDesc; + return maybeTemplates ? maybeTemplates.toJS() : maybeTemplates; } - else { - return this._resourceTypes[type].behaviors; - } + return Object.keys(this[typesKey]).reduce((prev, typeName) => { + prev[typeName] = this.urlTemplates(typeName); + return prev; + }, {}); } } -autoGetterSetterProps.forEach((propName) => { - ResourceTypeRegistry.prototype[propName] = makeGetterSetter(propName); +autoGetterProps.forEach((propName) => { + ResourceTypeRegistry.prototype[propName] = makeGetter(propName); }); +function makeGetter(attrName) { + return function(type) { + const maybeDesc = this[typesKey][type]; + const maybeVal = maybeDesc ? maybeDesc.get(attrName) : maybeDesc; -function makeGetterSetter(attrName) { - return function(type, optValue) { - this._resourceTypes[type] = this._resourceTypes[type] || {}; - - if(optValue) { - this._resourceTypes[type][attrName] = optValue; + if(maybeVal instanceof Immutable.Map || maybeVal instanceof Immutable.List) { + return maybeVal.toJS(); } - else { - return this._resourceTypes[type][attrName]; - } + return maybeVal; }; } diff --git a/src/controllers/API.js b/src/controllers/API.js index df12ccfa..dbfed070 100644 --- a/src/controllers/API.js +++ b/src/controllers/API.js @@ -72,7 +72,7 @@ class APIController { response.headers.vary = "Accept"; // If the type requested in the endpoint hasn't been registered, we 404. - if(!registry.type(request.type)) { + if(!registry.hasType(request.type)) { throw new APIError(404, undefined, `${request.type} is not a valid type.`); } diff --git a/src/controllers/Documentation.js b/src/controllers/Documentation.js index 206c363d..177fd0dd 100644 --- a/src/controllers/Documentation.js +++ b/src/controllers/Documentation.js @@ -27,8 +27,8 @@ export default class DocumentationController { // Store in the resourcesMap the info object about each type, // as returned by @getTypeInfo. - this.registry.types().forEach((type) => { - data.resourcesMap[type] = this.getTypeInfo(type); + this.registry.typeNames().forEach((typeName) => { + data.resourcesMap[typeName] = this.getTypeInfo(typeName); }); this.templateData = data; diff --git a/src/util/misc.js b/src/util/misc.js index 19692c11..639c519b 100644 --- a/src/util/misc.js +++ b/src/util/misc.js @@ -38,3 +38,53 @@ export function isSubsetOf(setArr, potentialSubsetArr) { export function isPlainObject(obj) { return typeof obj === "object" && !(Array.isArray(obj) || obj === null); } + +/** + * Perform a pseudo-topological sort on the provided graph. Pseudo because it + * assumes that each node only has 0 or 1 incoming edges, as is the case with + * graphs for parent-child inheritance hierarchies (w/o multiple inheritance). + * Uses https://en.wikipedia.org/wiki/Topological_sorting#Kahn.27s_algorithm + * + * @param {string[]} nodes A list of nodes, where each node is just a string. + * + * @param {string[]} roots The subset of nodes that have no incoming edges. + * + * @param {object} edges The edges, expressed such that each key is a starting + * node A, and the value is a set of nodes (as an object literal like + * {nodeName: true}) for each of which there is an edge from A to that node. + * + * @return {string[]} The nodes, sorted. + */ +export function pseudoTopSort(nodes, edges, roots) { + // Do some defensive copying, in case the caller didn't. + roots = roots.slice(); + nodes = nodes.slice(); + edges = Object.assign({}, edges); + for(const key in edges) { edges[key] = Object.assign({}, edges[key]); } + + // "L = Empty list that will contain the sorted elements" + const sortResult = []; + + // "while S is non-empty do" + while(roots.length) { + // "remove a node n from S" + const thisRoot = roots.pop(); + const thisRootChildren = edges[thisRoot] || {}; + + // "add n to tail of L" + sortResult.push(thisRoot); + + // "for each node m with an edge e from n to m do" + for(const child in thisRootChildren) { + // "remove edge e from the graph" + delete thisRootChildren[child]; + + // SKIP: "if m has no other incoming edges..." + // we don't need this check because we assumed max 1 incoming edge. + // But: "then insert m into S". + roots.push(child); + } + } + + return sortResult; +} diff --git a/test/app/database/models/organization.js b/test/app/database/models/organization.js index ff77d430..6bd76145 100755 --- a/test/app/database/models/organization.js +++ b/test/app/database/models/organization.js @@ -13,7 +13,8 @@ function OrganizationSchema() { description: { type: String }, - liaisons: [{ref: "Person", type: ObjectId}] + liaisons: [{ref: "Person", type: ObjectId}], + modified: Date }); } diff --git a/test/app/src/index.js b/test/app/src/index.js index f6b65a8f..631b9a7e 100755 --- a/test/app/src/index.js +++ b/test/app/src/index.js @@ -6,19 +6,19 @@ import database from "../database/index"; * Export a promise for the app. */ export default database.then(function(dbModule) { - const adapter = new API.dbAdapters.Mongoose(dbModule.models()) - , registry = new API.ResourceTypeRegistry() - , Controller = new API.controllers.API(registry); - - ["people", "organizations", "schools"].forEach(function(resourceType) { - const description = require("./resource-descriptions/" + resourceType); - description.dbAdapter = adapter; - registry.type(resourceType, description); + const adapter = new API.dbAdapters.Mongoose(dbModule.models()); + const registry = new API.ResourceTypeRegistry({ + "people": require("./resource-descriptions/people"), + "organizations": require("./resource-descriptions/organizations"), + "schools": require("./resource-descriptions/schools") + }, { + dbAdapter: adapter }); // Initialize the automatic documentation. // Note: don't do this til after you've registered all your resources.) const Docs = new API.controllers.Documentation(registry, {name: "Example API"}); + const Controller = new API.controllers.API(registry); // Initialize the express app + front controller. const app = express(); diff --git a/test/app/src/resource-descriptions/organizations.js b/test/app/src/resource-descriptions/organizations.js index 9b2eb41a..0f40a0f9 100755 --- a/test/app/src/resource-descriptions/organizations.js +++ b/test/app/src/resource-descriptions/organizations.js @@ -3,6 +3,12 @@ module.exports = { "self": "http://127.0.0.1:3000/organizations/{id}", "relationship": "http://127.0.0.1:3000/organizations/{ownerId}/links/{path}" }, + + beforeRender: function(resource) { + resource.attrs.addedBeforeRender = true; + return resource; + }, + beforeSave: function(resource) { resource.attrs.description = "Added a description in beforeSave"; return resource; diff --git a/test/app/src/resource-descriptions/schools.js b/test/app/src/resource-descriptions/schools.js index 6c71421e..ec3bd3a7 100755 --- a/test/app/src/resource-descriptions/schools.js +++ b/test/app/src/resource-descriptions/schools.js @@ -23,10 +23,10 @@ module.exports = { } }, - beforeSave: function(resource) { + beforeSave: function(resource, req, res, superFn) { return new Promise((resolve, reject) => { - resource.attrs.description = "Modified in a Promise"; + resource.attrs.modified = new Date("2015-10-27T05:16:57.257Z"); resolve(resource); - }); + }).then((transformed) => superFn(transformed, req, res)); } }; diff --git a/test/integration/create-resource/index.js b/test/integration/create-resource/index.js index 7f56eb7b..1588b8f3 100644 --- a/test/integration/create-resource/index.js +++ b/test/integration/create-resource/index.js @@ -59,15 +59,17 @@ describe("Creating Resources", () => { describe("beforeSave", () => { it("should execute beforeSave hook", () => { expect(createdResource.attributes.description).to.equal("Added a description in beforeSave"); + expect(createdResource.attributes.modified).to.equal("2015-01-01T00:00:00.000Z"); }); - it("should allow beforeSave to return a Promise", (done) => { + it("should allow beforeSave to return a Promise and support super()", (done) => { Agent.request("POST", "/schools") .type("application/vnd.api+json") .send({"data": VALID_SCHOOL_RESOURCE_NO_ID}) .promise() .then((response) => { - expect(response.body.data.attributes.description).to.equal("Modified in a Promise"); + expect(response.body.data.attributes.description).to.equal("Added a description in beforeSave"); + expect(response.body.data.attributes.modified).to.equal("2015-10-27T05:16:57.257Z"); done(); }, done).catch(done); }); diff --git a/test/integration/fetch-collection/index.js b/test/integration/fetch-collection/index.js index 77155edd..98f5e1a5 100644 --- a/test/integration/fetch-collection/index.js +++ b/test/integration/fetch-collection/index.js @@ -65,6 +65,18 @@ describe("Fetching Collection", () => { expect(it.data).to.not.be.undefined; //can be null, though }); }); + + // This test is good on its own, and the next couple tests also assume it passes. + it("should contain both organizations and schools", () => { + expect(res.body.data.some(it => it.type === "schools")).to.be.true; + expect(res.body.data.some(it => it.type === "organizations")).to.be.true; + }); + + it("should have transformed all resources, including sub types", () => { + expect(res.body.data.every(resource => { + return resource.attributes.addedBeforeRender; + })).to.be.true; + }); }); describe("Fetching Ascending Gendered Collection", () => { diff --git a/test/integration/fixtures/creation.js b/test/integration/fixtures/creation.js index 8231c14d..22a9c1d9 100644 --- a/test/integration/fixtures/creation.js +++ b/test/integration/fixtures/creation.js @@ -1,7 +1,8 @@ export const VALID_ORG_RESOURCE_NO_ID = { "type": "organizations", "attributes": { - "name": "Test Organization" + "name": "Test Organization", + "modified": "2015-01-01T00:00:00.000Z" }, "relationships": { "liaisons": { diff --git a/test/unit/ResourceTypeRegistry.js b/test/unit/ResourceTypeRegistry.js index a3066a38..82fa376c 100644 --- a/test/unit/ResourceTypeRegistry.js +++ b/test/unit/ResourceTypeRegistry.js @@ -1,86 +1,98 @@ import chai from "chai"; +import chaiSubset from "chai-subset"; import ResourceTypeRegistry from "../../src/ResourceTypeRegistry"; -let expect = chai.expect; -let makeGetterSetterTest = function(newThing, type, methodName, deep) { - let registry = new ResourceTypeRegistry(); +chai.use(chaiSubset); +const expect = chai.expect; +const makeGetterTest = function(value, type, methodName) { return function() { - expect(registry[methodName](type)).to.be.undefined; - registry[methodName](type, newThing); + let registry = new ResourceTypeRegistry({ + [type]: { + [methodName]: value + } + }); // You may get a copy of the set object back, not a direct - // reference. And that's acceptable. A deep check lets that pass. - if(deep) { - expect(registry[methodName](type)).to.deep.equal(newThing); - } - else { - expect(registry[methodName](type)).to.equal(newThing); + // reference. And that's preferable. A deep check lets that pass. + // value == null below is a hack around typeof null == "object". + switch((value === null) || typeof value) { + case "function": + expect(registry[methodName](type)).to.deep.equal(value); + break; + + // account for the possibility of other defaults + case "object": + expect(registry[methodName](type)).to.containSubset(value); + break; + + default: + expect(registry[methodName](type)).to.equal(value); } }; }; describe("ResourceTypeRegistry", function() { describe("constructor", () => { - it("should register resource descriptions provided in first parameter", () => { - let registry = new ResourceTypeRegistry([{ - type: "someType", - info: "provided to constructor" - }]); + it("should register provided resource descriptions", () => { + const registry = new ResourceTypeRegistry({ + "someType": { info: "provided to constructor" } + }); + expect(registry.type("someType")).to.be.an.object; expect(registry.type("someType").info).to.equal("provided to constructor"); }); - }); - describe("type", () => { it("should merge descriptionDefaults into resource description", () => { - let registry = new ResourceTypeRegistry([], { + const registry = new ResourceTypeRegistry({ + "someType": {} + }, { info: "provided as default" }); - registry.type("someType", {}); expect(registry.type("someType").info).to.equal("provided as default"); expect(registry.type("someType").behaviors).to.be.an("object"); }); it("should give the description precedence over the provided default", () => { - let registry = new ResourceTypeRegistry([], { - info: "provided as default" - }); - - let someType = { + const someTypeDesc = { info: "overriding the default", beforeSave: () => {}, beforeRender: () => {}, urlTemplates: {"path": "test template"} }; - registry.type("someType", someType); - let output = registry.type("someType"); + const registry = new ResourceTypeRegistry({ + "someType": someTypeDesc + }, { + info: "provided as default" + }); + + const output = registry.type("someType"); - expect(output.info).to.equal(someType.info); - expect(output.beforeSave).to.equal(someType.beforeSave); - expect(output.beforeRender).to.equal(someType.beforeRender); - expect(output.urlTemplates).to.deep.equal(someType.urlTemplates); + expect(output.info).to.deep.equal(someTypeDesc.info); + expect(output.beforeSave).to.equal(someTypeDesc.beforeSave); + expect(output.beforeRender).to.equal(someTypeDesc.beforeRender); + expect(output.urlTemplates).to.deep.equal(someTypeDesc.urlTemplates); }); it("should give description and resource defaults precedence over global defaults", () => { - let registry = new ResourceTypeRegistry([{ - "type": "testType", - "behaviors": { - "dasherizeOutput": { - "enabled": true + const registry = new ResourceTypeRegistry({ + "testType": { + "behaviors": { + "dasherizeOutput": { + "enabled": true + } } - } + }, + "testType2": {} }, { - "type": "testType2" - }], { "behaviors": { "dasherizeOutput": {"enabled": false, "exceptions": []} } }); - let testTypeOutput = registry.type("testType"); - let testType2Output = registry.type("testType2"); + const testTypeOutput = registry.type("testType"); + const testType2Output = registry.type("testType2"); expect(testTypeOutput.behaviors.dasherizeOutput.enabled).to.be.true; expect(testType2Output.behaviors.dasherizeOutput.enabled).to.be.false; @@ -88,58 +100,86 @@ describe("ResourceTypeRegistry", function() { }); }); - describe("behaviors", () => { - it("should merge in provided behaviors config", () => { - let registry = new ResourceTypeRegistry(); - registry.behaviors("testType", {"dasherizeOutput": {exceptions: {}}}); + it("Should allow null/undefined to overwrite all defaults", () => { + const registry = new ResourceTypeRegistry({ + "testType": { + "behaviors": null + } + }, { + "behaviors": { + "dasherizeOutput": {"enabled": false, "exceptions": []} + } + }); + + expect(registry.behaviors("testType")).to.equal(null); + }) + + describe("urlTemplates()", () => { + it("should return a copy of the templates for all types", () => { + const aTemps = {"self": ""}; + const bTemps = {"related": ""}; + const typeDescs = { + "a": {"urlTemplates": aTemps}, + "b": {"urlTemplates": bTemps} + }; + const registry = new ResourceTypeRegistry(typeDescs); - // the global default shouldn't have been replaced over by the set above. - expect(registry.behaviors("testType").dasherizeOutput.enabled).to.be.true; + expect(registry.urlTemplates()).to.not.equal(typeDescs); + expect(registry.urlTemplates()).to.containSubset({"a": aTemps, "b": bTemps}); }); }); + describe("urlTemplates(type)", () => { + it("should be a getter for a type's urlTemplates", + makeGetterTest({"path": "test template"}, "mytypes", "urlTemplates") + ); + }); + + describe("behaviors", () => { + it("should be a getter for a type's behaviors", + makeGetterTest({"someSetting": true}, "mytypes", "behaviors") + ); + }); + describe("adapter", () => { - it("should be a getter/setter for a type's db adapter", - makeGetterSetterTest({"a": "new model"}, "mytypes", "dbAdapter") + it("should be a getter for a type's db adapter", + makeGetterTest(function() {}, "mytypes", "dbAdapter") ); }); describe("beforeSave", () => { - it("should be a getter/setter for a type for a type's beforeSave", - makeGetterSetterTest(() => {}, "mytypes", "beforeSave") + it("should be a getter for a type for a type's beforeSave", + makeGetterTest(() => {}, "mytypes", "beforeSave") ); }); describe("beforeRender", () => { - it("should be a getter/setter for a type's beforeRender", - makeGetterSetterTest(() => {}, "mytypes", "beforeRender") + it("should be a getter for a type's beforeRender", + makeGetterTest(() => {}, "mytypes", "beforeRender") ); }); describe("labelMappers", () => { - it("should be a getter/setter for a type's labelMappers", - makeGetterSetterTest({"label": () => {}}, "mytypes", "labelMappers") + it("should be a getter for a type's labelMappers", + makeGetterTest({"label": () => {}}, "mytypes", "labelMappers") ); }); describe("info", () => { - it("should be a getter/setter for a type's info", - makeGetterSetterTest({}, "mytypes", "info") + it("should be a getter for a type's info", + makeGetterTest({}, "mytypes", "info") ); }); describe("parentType", () => { - it("should be a getter/setter for a type for a type's parentType", - makeGetterSetterTest(() => "my-parents", "mytypes", "parentType") - ); - }); + const registry = new ResourceTypeRegistry({ + "b": {"parentType": "a", "info": {"x": true}}, + "a": {"info": {"y": false}} + }); - describe("urlTemplates", () => { - it("should be a getter/setter for a type's urlTemplates", - makeGetterSetterTest( - {"path": "test template"}, - "mytypes", "urlTemplates", true - ) - ); + it("should be a getter for a type for a type's parentType", () => { + expect(registry.parentType("b")).to.equal("a"); + expect(registry.parentType("a")).to.be.undefined; + }); }); }); diff --git a/test/unit/util/misc.js b/test/unit/util/misc.js index 7f367af0..7e0cbe22 100644 --- a/test/unit/util/misc.js +++ b/test/unit/util/misc.js @@ -49,4 +49,23 @@ describe("Utility methods", () => { expect(utils.isSubsetOf(["false"], [0])).to.be.false; }); }); + + describe("pseudoTopSort", function () { + it("should sort the items correctly", function () { + var nodes = ["c", "b", "f", "a", "d", "e"]; + var roots = ["a", "d", "f"]; + var edges = { "a": { "b": true }, "b": { "c": true }, "d": { "e": true } }; + var sorted = utils.pseudoTopSort(nodes, edges, roots); + + // check that all the nodes were returned exactly once. + expect(sorted.length).to.equal(6); + expect(nodes.every(function (node) { + return sorted.indexOf(node) > -1; + })).to.be["true"]; + + expect(sorted.indexOf("b")).to.be.gt(sorted.indexOf("a")); + expect(sorted.indexOf("c")).to.be.gt(sorted.indexOf("b")); + expect(sorted.indexOf("e")).to.be.gt(sorted.indexOf("d")); + }); + }); });