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:
parent
5fd43bf62d
commit
f3cfab636e
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue