diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0d98a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +dist/** +docs/generated/** +lib/** +*.css +*.d.ts +*.js* +!gulpfile.js +*.html +node_modules/ +npm-debug.log +debug.log diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..6f121f6 --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +node_modules/ +test/**/* +npm-debug.log diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0820099 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: node_js + +node_js: + - "7" + - "5" + +script: + node_modules/gulp/bin/gulp.js diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..e4fcb9d --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,18 @@ +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b21eaa --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ + +# featureboxr +[![Build Status](https://travis-ci.org/FullScreenShenanigans/featureboxr.svg?branch=master)](https://travis-ci.org/FullScreenShenanigans/featureboxr) +[![NPM version](https://badge.fury.io/js/featureboxr.svg)](http://badge.fury.io/js/featureboxr) + +Gates features behind generational gaps. + + + +## Development + +See [Documentation/Development](https://github.com/FullScreenShenanigans/Documentation). + + + diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..5b7a4f9 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1 @@ +require("gulp-shenanigans").initialize(require("gulp")); diff --git a/src/FeatureBoxr.ts b/src/FeatureBoxr.ts new file mode 100644 index 0000000..3951928 --- /dev/null +++ b/src/FeatureBoxr.ts @@ -0,0 +1,95 @@ +import { IFeatureBoxr, IFeatureBoxrSettings, IGenerations } from "./IFeatureBoxr"; + +/** + * Creates a get-only version of a matched features object. + * + * @type TFeatures Generation-variant features. + * @param matchedFeatures Matched features for a generation. + * @returns A get-only version of the matched features object. + */ +const generateGettableFeatures = (matchedFeatures: Partial): TFeatures => { + const features = {} as TFeatures; + + for (const featureName in matchedFeatures) { + Object.defineProperty(features, featureName, { + get() { + return matchedFeatures[featureName]; + } + }); + } + + console.log( + "Computed", + Object.keys(matchedFeatures), + Object.keys(matchedFeatures).map((key) => (features as any)[key])); + return features; +}; + +/** + * Gates features behind generational gaps. + * + * @type TFeatures Generation-variant features. + */ +export class FeatureBoxr implements IFeatureBoxr { + /** + * Generation-variant features. + */ + public get features(): TFeatures { + return this.cachedFeatures; + } + + /** + * Groups of feature settings, in order. + */ + private readonly generations: IGenerations; + + /** + * Names of the available feature boxes. + */ + private readonly generationNames: string[]; + + /** + * Feature availabilities cached by this.reset. + */ + private cachedFeatures: TFeatures; + + /** + * Initializes a new instance of the FeatureBoxr class. + * + * @param settings Settings to be used for initialization. + */ + public constructor(settings: IFeatureBoxrSettings) { + this.generations = settings.generations; + this.generationNames = Object.keys(this.generations); + + this.setGeneration(settings.generation || Object.keys(this.generations)[0]); + } + + /** + * Sets features to a generation. + * + * @param generation Generation for feature availability. + */ + public setGeneration(generationName: string): void { + const indexOf: number = this.generationNames.indexOf(generationName); + console.log("eyy", this.generationNames); + + if (indexOf === -1) { + throw new Error(`Unknown generation: '${generationName}'.`); + } + + const matchedFeatures: Partial = {}; + + for (let i: number = 0; i <= indexOf; i += 1) { + const generation = this.generations[this.generationNames[i]]; + console.log("From", generation); + + for (const featureName in generation) { + console.log("\tfeatureName", featureName, generation[featureName]); + matchedFeatures[featureName] = generation[featureName]; + } + } + + this.cachedFeatures = generateGettableFeatures(matchedFeatures); + } +} diff --git a/src/IFeatureBoxr.ts b/src/IFeatureBoxr.ts new file mode 100644 index 0000000..e22320c --- /dev/null +++ b/src/IFeatureBoxr.ts @@ -0,0 +1,26 @@ +export interface IGenerations { + [i: string]: Partial; +} + +/** + * Settings to initialize a new instance of the FeatureBoxr class. + * + * @type TFeatures Generation-variant features. + */ +export interface IFeatureBoxrSettings { + /** + * Groups of feature settings, in order. + */ + generations: IGenerations; + + /** + * Starting generation to enable, if not the first keyed. + */ + generation?: string; +} + +export interface IFeatureBoxr { + readonly features: TFeatures; + + setGeneration(generationName: string): void; +} diff --git a/test/FeatureBoxr.ts b/test/FeatureBoxr.ts new file mode 100644 index 0000000..cf0c6a8 --- /dev/null +++ b/test/FeatureBoxr.ts @@ -0,0 +1,99 @@ +import { FeatureBoxr } from "../src/featureBoxr"; +import { mochaLoader } from "./main"; + +const [first, second, value] = ["first", "second", "value"]; + +mochaLoader.it("gets a feature setting from a first generation", (): void => { + // Arrange + const featureBoxer = new FeatureBoxr<{ value: string }>({ + generations: { + [first]: { value } + } + }); + + // Act + const currentValue = featureBoxer.features.value; + + // Assert + chai.expect(currentValue).to.be.equal(value); +}); + +mochaLoader.it("gets a feature setting from a second generation", (): void => { + // Arrange + const featureBoxer = new FeatureBoxr<{ value: string }>({ + generations: { + [first]: {}, + [second]: { value } + }, + generation: second + }); + + // Act + const currentValue = featureBoxer.features.value; + + // Assert + chai.expect(currentValue).to.be.equal(value); +}); + +mochaLoader.it("gets an overridden feature setting from a second generation", (): void => { + // Arrange + const featureBoxer = new FeatureBoxr<{ value: string }>({ + generations: { + [first]: { + [value]: "wrong" + }, + [second]: { value } + }, + generation: second + }); + + // Act + const currentValue = featureBoxer.features.value; + + // Assert + chai.expect(currentValue).to.be.equal(value); +}); + +mochaLoader.it("gets an first feature setting from resetting to a first generation", (): void => { + // Arrange + const featureBoxer = new FeatureBoxr<{ value: string }>({ + generations: { + [first]: { + value: first + }, + [second]: { + value: second + } + }, + generation: second + }); + + // Act + featureBoxer.setGeneration(first); + const currentValue = featureBoxer.features.value; + + // Assert + chai.expect(currentValue).to.be.equal(first); +}); + +mochaLoader.it("gets an second feature setting from resetting to a second generation", (): void => { + // Arrange + const featureBoxer = new FeatureBoxr<{ value: string }>({ + generations: { + [first]: { + value: first + }, + [second]: { + value: second + } + }, + generation: first + }); + + // Act + featureBoxer.setGeneration(second); + const currentValue = featureBoxer.features.value; + + // Assert + chai.expect(currentValue).to.be.equal(second); +}); diff --git a/test/main.ts b/test/main.ts new file mode 100644 index 0000000..fd261d6 --- /dev/null +++ b/test/main.ts @@ -0,0 +1,79 @@ +/* This file was auto-generated by gulp-shenanigans */ + +import { MochaLoader } from "./utils/MochaLoader"; + +declare const requirejs: any; +declare const testDependencies: string[]; +declare const testPaths: string[]; + +/** + * Combines mocha tests into their describe() groups. + */ +export const mochaLoader: MochaLoader = new MochaLoader(mocha); + +/** + * Adds a new test under the current test path. + * + * @param testName The name of the test. + * @param test A new test. + */ +export const it: typeof mochaLoader.it = mochaLoader.it.bind(mochaLoader); + +/** + * Adds a new test under a custom test path. + * + * @param path Extra path after the current test path. + * @param testName The name of the test. + * @param test A new test. + */ +export const under: typeof mochaLoader.under = mochaLoader.under.bind(mochaLoader); + +/** + * Informs RequireJS of the file location for a test dependency. + * + * @param testDependencies Modules depended upon for tests. + */ +function redirectTestDependencies(dependencies: string[]): void { + for (const dependency of dependencies) { + requirejs.config({ + paths: { + [dependency.toLowerCase() + "/lib"]: `../node_modules/${dependency.toLowerCase()}/src` + } + }); + } +} + +/** + * Recursively loads test paths under mocha loader. + * + * @param loadingPaths Test paths to load. + * @param i Which index test path to load. + * @param onComplete A callback for when loading is done. + */ +function loadTestPaths(loadingPaths: string[], i: number, onComplete: () => void): void { + "use strict"; + + if (i >= loadingPaths.length) { + onComplete(); + return; + } + + mochaLoader.setTestPath(loadingPaths[i]); + requirejs( + [loadingPaths[i]], + (): void => { + loadTestPaths(loadingPaths, i + 1, onComplete); + }); +} + +((): void => { + redirectTestDependencies(testDependencies); + + loadTestPaths( + testPaths, + 0, + (): void => { + mochaLoader.describeTests(); + mochaLoader.run(); + }); +})(); diff --git a/test/utils/MochaLoader.ts b/test/utils/MochaLoader.ts new file mode 100644 index 0000000..96ce6ba --- /dev/null +++ b/test/utils/MochaLoader.ts @@ -0,0 +1,133 @@ +/* This file was auto-generated by gulp-shenanigans */ + +/** + * Grouping of mocha describe() tests. + */ +interface ITestHierarchy { + /** + * Hierarchical children within this describe() group. + */ + children: { + [i: string]: ITestHierarchy; + }; + + /** + * Tests run in this describe(). + */ + tests: { + [i: string]: (done: Function) => void; + }; +} + +/** + * Combines mocha tests into their describe() groups. + */ +export class MochaLoader { + /** + * The underlying mocha instance. + */ + private readonly mocha: Mocha; + + /** + * Root grouping of test hierarchies. + */ + private readonly testHierarchy: ITestHierarchy = { + children: {}, + tests: {} + }; + + /** + * Mocha describe() path for the next test to be added. + */ + private currentTestPath: string[]; + + /** + * Initializes a new instance of the MochaLoader class. + * + * @param mocha The underlying mocha instance. + */ + public constructor(mocha: Mocha) { + this.mocha = mocha; + this.mocha.setup("bdd"); + } + + /** + * Sets the current test path. + * + * @param rawPath A new current test path. + */ + public setTestPath(rawPath: string): void { + this.currentTestPath = rawPath.split("/"); + } + + /** + * Adds a new test under the current test path. + * + * @param testName The name of the test. + * @param test A new test. + */ + public it(testName: string, test: (done: Function) => void): void { + this.under([], testName, test); + } + + /** + * Adds a new test under a custom test path. + * + * @param path Extra path after the current test path. + * @param testName The name of the test. + * @param test A new test. + */ + public under(path: string[], testName: string, test: (done: Function) => void): void { + if (!this.currentTestPath) { + throw new Error(`No test path defined before adding test '${testName}'.`); + } + + let testHierarchy: ITestHierarchy = this.testHierarchy; + + for (const part of [...this.currentTestPath, ...path]) { + if (!testHierarchy.children[part]) { + testHierarchy = testHierarchy.children[part] = { + children: {}, + tests: {} + }; + } else { + testHierarchy = testHierarchy.children[part]; + } + } + + testHierarchy.tests[testName] = test; + } + + /** + * Finalizes the tests' describe() hierarchy. + */ + public describeTests(): void { + this.describeTestHierarchy(this.testHierarchy); + } + + /** + * Runs tests using mocha. + */ + public run(): void { + this.mocha.run(); + } + + /** + * Recursively describes a test hierarchy and its children hierarchies. + * + * @param testHierarchy A test hierarchy to describe. + */ + private describeTestHierarchy(testHierarchy: ITestHierarchy): void { + for (const testName in testHierarchy.tests) { + if (testName in testHierarchy.tests) { + it(testName, testHierarchy.tests[testName]); + } + } + + for (const childName in testHierarchy.children) { + if (childName in testHierarchy.children) { + describe(childName, (): void => this.describeTestHierarchy(testHierarchy.children[childName])); + } + } + } +}