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.
This commit is contained in:
Robin van Boven 2020-02-04 22:14:21 +01:00 committed by GitHub
parent 5fd43bf62d
commit f3cfab636e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 318 additions and 0 deletions

125
src/backend/loadContext.js Normal file
View File

@ -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<TimelineCredParameters>,
|};
/**
* 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<LoadResult> {
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,
};
}
}

View File

@ -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,
});
});
});
});
});