From f3cfab636e00918238fccfc1048022a13505e536 Mon Sep 17 00:00:00 2001 From: Robin van Boven <497556+Beanow@users.noreply.github.com> Date: Tue, 4 Feb 2020 22:14:21 +0100 Subject: [PATCH] Backend: implement LoadContext (#1621) Follows the general outline of #1586. It uses a new trick of aliasing external module functions as private properties. This makes the spyOn / mock tests more robust, while fitting in the composition responsibility. --- src/backend/loadContext.js | 125 +++++++++++++++++++++ src/backend/loadContext.test.js | 193 ++++++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 src/backend/loadContext.js create mode 100644 src/backend/loadContext.test.js diff --git a/src/backend/loadContext.js b/src/backend/loadContext.js new file mode 100644 index 0000000..59bb202 --- /dev/null +++ b/src/backend/loadContext.js @@ -0,0 +1,125 @@ +//@flow + +import {type Project} from "../core/project"; +import {type Weights as WeightsT} from "../core/weights"; +import {type WeightedGraph as WeightedGraphT} from "../core/weightedGraph"; +import * as WeightedGraph from "../core/weightedGraph"; +import {type TimelineCredParameters} from "../analysis/timeline/params"; +import {type GithubToken} from "../plugins/github/token"; +import {type CacheProvider} from "./cache"; +import {TaskReporter} from "../util/taskReporter"; +import {TimelineCred} from "../analysis/timeline/timelineCred"; +import {type ComputeFunction as ComputeFunctionT} from "./computeFunction"; +import {type PluginLoaders as PluginLoadersT} from "./pluginLoaders"; +import * as ComputeFunction from "./computeFunction"; +import * as PluginLoaders from "./pluginLoaders"; +import {default as githubLoader} from "../plugins/github/loader"; +import {default as identityLoader} from "../plugins/identity/loader"; +import {default as discourseLoader} from "../plugins/discourse/loader"; +import {type PluginDeclarations} from "../analysis/pluginDeclaration"; + +export type LoadResult = {| + +pluginDeclarations: PluginDeclarations, + +weightedGraph: WeightedGraphT, + +cred: TimelineCred, +|}; + +export type LoadContextOptions = {| + +cache: CacheProvider, + +reporter: TaskReporter, + +githubToken: ?GithubToken, +|}; + +type OptionalLoadArguments = {| + +weightsOverrides?: WeightsT, + +params?: $Shape, +|}; + +/** + * This class is responsible composing all the variables and concrete functions + * of the loading process. + * + * Specifically it composes: + * - The loading environment (through the constructor). + * - Concrete functions of the loading process (internally). + * - Parameters for a load (Project and TimelineCredParameters). + * + * You can think of LoadContext as an instance where the environment and + * functions have been composed so it's ready to perform a load with. + */ +export class LoadContext { + +_options: LoadContextOptions; + + constructor(opts: LoadContextOptions) { + this._options = opts; + } + + /** + * Here we're exposing multiple "proxy functions". + * They're just aliases of functions from another module. But by aliasing them + * as private properties we allow test code to spyOn these per LoadContext + * instance, and without needing to know details of the external modules. + * + * Of course this would break if the external module changes, but that would + * also occur if we directly depended on them. + */ + + +_declarations = PluginLoaders.declarations; + +_updateMirror = PluginLoaders.updateMirror; + +_createPluginGraphs = PluginLoaders.createPluginGraphs; + +_contractPluginGraphs = PluginLoaders.contractPluginGraphs; + +_overrideWeights = WeightedGraph.overrideWeights; + +_computeTask = ComputeFunction.computeTask; + + /** + * The above proxy functions we're deferring to, accept interfaces so they + * could easily be mocked. This class takes the role of composing the concrete + * implementations though. So we're exposing them as aliases here, similar to + * the functions. As we'll need to test if these have been correctly passed on. + */ + + +_compute: ComputeFunctionT = TimelineCred.compute; + +_pluginLoaders: PluginLoadersT = { + github: githubLoader, + discourse: discourseLoader, + identity: identityLoader, + }; + + /** + * Performs a load in this context. + */ + async load( + project: Project, + {params, weightsOverrides}: OptionalLoadArguments + ): Promise { + const cachedProject = await this._updateMirror( + this._pluginLoaders, + this._options, + project + ); + const pluginGraphs = await this._createPluginGraphs( + this._pluginLoaders, + this._options, + cachedProject + ); + const contractedGraph = await this._contractPluginGraphs( + this._pluginLoaders, + pluginGraphs + ); + let weightedGraph = contractedGraph; + if (weightsOverrides) { + weightedGraph = this._overrideWeights(contractedGraph, weightsOverrides); + } + const plugins = this._declarations(this._pluginLoaders, project); + const cred = await this._computeTask(this._compute, this._options, { + params, + plugins, + weightedGraph, + }); + return { + pluginDeclarations: plugins, + weightedGraph, + cred, + }; + } +} diff --git a/src/backend/loadContext.test.js b/src/backend/loadContext.test.js new file mode 100644 index 0000000..54aebbe --- /dev/null +++ b/src/backend/loadContext.test.js @@ -0,0 +1,193 @@ +// @flow + +import {type CacheProvider} from "./cache"; +import {type Project, createProject} from "../core/project"; +import * as Weights from "../core/weights"; +import {validateToken} from "../plugins/github/token"; +import {TestTaskReporter} from "../util/taskReporter"; +import {LoadContext} from "./loadContext"; + +const fakeDeclarations = (["fake-declaration"]: any); +const fakePluginGraphs = ({is: "fake-plugin-graphs"}: any); +const fakeContractedGraph = ({is: "fake-contracted-graph"}: any); +const fakeWeightedGraph = ({is: "fake-weighted-graph"}: any); +const fakeCred = ({is: "fake-cred"}: any); + +const mockCache = (): CacheProvider => ({ + database: jest.fn(), +}); + +const calledMoreThanOnce = (name: string) => () => { + throw new Error(`Called ${name} more than once`); +}; + +const mockProxyMethods = ( + loadContext: LoadContext, + project: Project, + cache: CacheProvider +) => ({ + declarations: jest + .spyOn(loadContext, "_declarations") + .mockImplementation(calledMoreThanOnce("_declarations")) + .mockReturnValueOnce(fakeDeclarations), + updateMirror: jest + .spyOn(loadContext, "_updateMirror") + .mockImplementation(calledMoreThanOnce("_updateMirror")) + .mockResolvedValueOnce({project, cache}), + createPluginGraphs: jest + .spyOn(loadContext, "_createPluginGraphs") + .mockImplementation(calledMoreThanOnce("_createPluginGraphs")) + .mockResolvedValueOnce(fakePluginGraphs), + contractPluginGraphs: jest + .spyOn(loadContext, "_contractPluginGraphs") + .mockImplementation(calledMoreThanOnce("_contractPluginGraphs")) + .mockReturnValueOnce(fakeContractedGraph), + overrideWeights: jest + .spyOn(loadContext, "_overrideWeights") + .mockImplementation(calledMoreThanOnce("_overrideWeights")) + .mockReturnValueOnce(fakeWeightedGraph), + computeTask: jest + .spyOn(loadContext, "_computeTask") + .mockImplementation(calledMoreThanOnce("_computeTask")) + .mockResolvedValueOnce(fakeCred), +}); + +describe("src/backend/loadContext", () => { + describe("LoadContext", () => { + const githubToken = validateToken("0".repeat(40)); + const project = createProject({id: "testing-project"}); + const params = {alpha: 0.123}; + + describe("constructor", () => { + /** + * Note: we're not testing the proxy properties are the "correct" ones. + * This would be too constraining. Instead we should use an integration + * test to see if the results are as expected. However they should be + * exposed, in order to validate they are correctly called during `load`. + */ + it("should expose proxy properties", () => { + // Given + const cache = mockCache(); + const reporter = new TestTaskReporter(); + + // When + const loadContext = new LoadContext({cache, githubToken, reporter}); + + // Then + expect(loadContext).toMatchObject({ + // Properties + _compute: expect.anything(), + _pluginLoaders: expect.anything(), + + // Methods + _declarations: expect.anything(), + _updateMirror: expect.anything(), + _createPluginGraphs: expect.anything(), + _contractPluginGraphs: expect.anything(), + _overrideWeights: expect.anything(), + _computeTask: expect.anything(), + }); + }); + }); + + describe("load", () => { + it("should call proxy methods with correct arguments", async () => { + // Given + const cache = mockCache(); + const reporter = new TestTaskReporter(); + const weightsOverrides = Weights.empty(); + const loadContext = new LoadContext({cache, githubToken, reporter}); + const spies = mockProxyMethods(loadContext, project, cache); + + // When + await loadContext.load(project, {weightsOverrides, params}); + + // Then + const cachedProject = {project, cache}; + const expectedEnv = { + githubToken, + reporter, + cache, + }; + expect(spies.declarations).toBeCalledWith( + loadContext._pluginLoaders, + project + ); + expect(spies.updateMirror).toBeCalledWith( + loadContext._pluginLoaders, + expectedEnv, + project + ); + expect(spies.createPluginGraphs).toBeCalledWith( + loadContext._pluginLoaders, + expectedEnv, + cachedProject + ); + expect(spies.contractPluginGraphs).toBeCalledWith( + loadContext._pluginLoaders, + fakePluginGraphs + ); + expect(spies.overrideWeights).toBeCalledWith( + fakeContractedGraph, + weightsOverrides + ); + expect(spies.computeTask).toBeCalledWith( + loadContext._compute, + expectedEnv, + {weightedGraph: fakeWeightedGraph, plugins: fakeDeclarations, params} + ); + }); + + it("should support omitting optional arguments", async () => { + // Given + const cache = mockCache(); + const reporter = new TestTaskReporter(); + const loadContext = new LoadContext({cache, githubToken, reporter}); + const spies = mockProxyMethods(loadContext, project, cache); + + // When + await loadContext.load(project, {}); + + // Then + const expectedEnv = { + githubToken, + reporter, + cache, + }; + + // Omitting weight overrides option, should not call this function. + expect(spies.overrideWeights).toBeCalledTimes(0); + + // We have a different input graph, because weight overrides wasn't called. + // We're omitting the `params` argument from the options. + expect(spies.computeTask).toBeCalledWith( + loadContext._compute, + expectedEnv, + {weightedGraph: fakeContractedGraph, plugins: fakeDeclarations} + ); + }); + + it("should return a LoadResult", async () => { + // Given + const cache = mockCache(); + const reporter = new TestTaskReporter(); + const weightsOverrides = Weights.empty(); + const loadContext = new LoadContext({cache, githubToken, reporter}); + mockProxyMethods(loadContext, project, cache); + + // When + const result = await loadContext.load(project, { + weightsOverrides, + params, + }); + + // Then + expect(result).toEqual({ + pluginDeclarations: fakeDeclarations, + weightedGraph: fakeWeightedGraph, + cred: fakeCred, + }); + }); + }); + }); +});