Implement AppState and StateTransitionMachine (#579)

The cred explorer app has a variety of valid states. Currently, it is
thrown together without explicit documentation of what its states are,
how they transition, or error handling or testing. I worry that this
will be hard to maintain.

This commit creates the AppState type which explicitly reifies every
reachable state for the app, and a StateTransitionMachine which handles
transitions between states. The transitions are thoroughly tested,
including edge cases where the user makes a change while waiting for a
promise to resolve, or where one of the promises failes.

Test plan:
The unit tests are comprehensive. `yarn test` passes.

Thanks to @wchargin for much discussion about how to structure the
states.
This commit is contained in:
Dandelion Mané 2018-08-02 19:18:33 -07:00 committed by GitHub
parent 038d166972
commit 56c17d2597
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 572 additions and 0 deletions

View File

@ -0,0 +1,258 @@
// @flow
import deepEqual from "lodash.isequal";
import * as NullUtil from "../../util/null";
import {Graph} from "../../core/graph";
import type {Repo} from "../../core/repo";
import {type EdgeEvaluator} from "../../core/attribution/pagerank";
import {
type PagerankNodeDecomposition,
type PagerankOptions,
pagerank,
} from "../../core/attribution/pagerank";
import type {DynamicPluginAdapter} from "../pluginAdapter";
import {StaticPluginAdapter as GitAdapter} from "../../plugins/git/pluginAdapter";
import {StaticPluginAdapter as GithubAdapter} from "../../plugins/github/pluginAdapter";
/*
This models the UI states of the credExplorer/App as a state machine.
The different states are all instances of AppState, and the transitions are
explicitly managed by the StateTransitionMachine class. All of the
transitions, including error cases, are thoroughly tested.
*/
export type AppState = Uninitialized | Initialized;
export type Uninitialized = {|
+type: "UNINITIALIZED",
edgeEvaluator: ?EdgeEvaluator,
repo: ?Repo,
|};
export type Initialized = {|
+type: "INITIALIZED",
+edgeEvaluator: EdgeEvaluator,
+repo: Repo,
+substate: AppSubstate,
|};
export type LoadingState = "NOT_LOADING" | "LOADING" | "FAILED";
export type AppSubstate =
| ReadyToLoadGraph
| ReadyToRunPagerank
| PagerankEvaluated;
export type ReadyToLoadGraph = {|
+type: "READY_TO_LOAD_GRAPH",
+loading: LoadingState,
|};
export type ReadyToRunPagerank = {|
+type: "READY_TO_RUN_PAGERANK",
+graphWithAdapters: GraphWithAdapters,
+loading: LoadingState,
|};
export type PagerankEvaluated = {|
+type: "PAGERANK_EVALUATED",
+graphWithAdapters: GraphWithAdapters,
pagerankNodeDecomposition: PagerankNodeDecomposition,
+loading: LoadingState,
|};
export function createStateTransitionMachine(
getState: () => AppState,
setState: (AppState) => void
): StateTransitionMachine {
return new StateTransitionMachine(
getState,
setState,
loadGraphWithAdapters,
pagerank
);
}
export function initialState(): AppState {
return {type: "UNINITIALIZED", repo: null, edgeEvaluator: null};
}
// Exported for testing purposes.
export interface StateTransitionMachineInterface {
+setRepo: (Repo) => void;
+setEdgeEvaluator: (EdgeEvaluator) => void;
+loadGraph: () => Promise<void>;
+runPagerank: () => Promise<void>;
}
/* In production, instantiate via createStateTransitionMachine; the constructor
* implementation allows specification of the loadGraphWithAdapters and
* pagerank functions for DI/testing purposes.
**/
export class StateTransitionMachine implements StateTransitionMachineInterface {
getState: () => AppState;
setState: (AppState) => void;
loadGraphWithAdapters: (repo: Repo) => Promise<GraphWithAdapters>;
pagerank: (
Graph,
EdgeEvaluator,
PagerankOptions
) => Promise<PagerankNodeDecomposition>;
constructor(
getState: () => AppState,
setState: (AppState) => void,
loadGraphWithAdapters: (repo: Repo) => Promise<GraphWithAdapters>,
pagerank: (
Graph,
EdgeEvaluator,
PagerankOptions
) => Promise<PagerankNodeDecomposition>
) {
this.getState = getState;
this.setState = setState;
this.loadGraphWithAdapters = loadGraphWithAdapters;
this.pagerank = pagerank;
}
_maybeInitialize(state: Uninitialized): AppState {
const {repo, edgeEvaluator} = state;
if (repo != null && edgeEvaluator != null) {
const substate = {type: "READY_TO_LOAD_GRAPH", loading: "NOT_LOADING"};
return {type: "INITIALIZED", repo, edgeEvaluator, substate};
} else {
return state;
}
}
setRepo(repo: Repo) {
const state = this.getState();
switch (state.type) {
case "UNINITIALIZED": {
const newState = this._maybeInitialize({...state, repo});
this.setState(newState);
break;
}
case "INITIALIZED": {
const substate = {type: "READY_TO_LOAD_GRAPH", loading: "NOT_LOADING"};
const newState = {...state, repo, substate};
this.setState(newState);
break;
}
default: {
throw new Error((state.type: empty));
}
}
}
setEdgeEvaluator(edgeEvaluator: EdgeEvaluator) {
const state = this.getState();
switch (state.type) {
case "UNINITIALIZED": {
const newState = this._maybeInitialize({...state, edgeEvaluator});
this.setState(newState);
break;
}
case "INITIALIZED": {
const newState = {...state, edgeEvaluator};
this.setState(newState);
break;
}
default: {
throw new Error((state.type: empty));
}
}
}
async loadGraph() {
const state = this.getState();
if (
state.type !== "INITIALIZED" ||
state.substate.type !== "READY_TO_LOAD_GRAPH"
) {
throw new Error("Tried to loadGraph in incorrect state");
}
const {repo, substate} = state;
const loadingState = {
...state,
substate: {...substate, loading: "LOADING"},
};
this.setState(loadingState);
let newState: ?AppState;
try {
const graphWithAdapters = await this.loadGraphWithAdapters(repo);
newState = {
...state,
substate: {
type: "READY_TO_RUN_PAGERANK",
graphWithAdapters,
loading: "NOT_LOADING",
},
};
} catch (e) {
console.error(e);
newState = {...state, substate: {...substate, loading: "FAILED"}};
}
if (deepEqual(this.getState(), loadingState)) {
this.setState(newState);
}
}
async runPagerank() {
const state = this.getState();
if (
state.type !== "INITIALIZED" ||
state.substate.type === "READY_TO_LOAD_GRAPH"
) {
throw new Error("Tried to runPagerank in incorrect state");
}
const {edgeEvaluator, substate} = state;
// Oh, the things we must do to appease flow
const loadingSubstate =
substate.type === "PAGERANK_EVALUATED"
? {...substate, loading: "LOADING"}
: {...substate, loading: "LOADING"};
const loadingState = {
...state,
substate: loadingSubstate,
};
this.setState(loadingState);
const graph = substate.graphWithAdapters.graph;
let newState: ?AppState;
try {
const pagerankNodeDecomposition = await this.pagerank(
graph,
edgeEvaluator,
{
verbose: true,
}
);
const newSubstate = {
type: "PAGERANK_EVALUATED",
graphWithAdapters: substate.graphWithAdapters,
pagerankNodeDecomposition,
loading: "NOT_LOADING",
};
newState = {...state, substate: newSubstate};
} catch (e) {
console.error(e);
const failedSubstate =
// More flow appeasement
substate.type === "PAGERANK_EVALUATED"
? {...substate, loading: "FAILED"}
: {...substate, loading: "FAILED"};
newState = {...state, substate: failedSubstate};
}
if (deepEqual(this.getState(), loadingState)) {
this.setState(NullUtil.get(newState));
}
}
}
export type GraphWithAdapters = {|
+graph: Graph,
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
|};
export function loadGraphWithAdapters(repo: Repo): Promise<GraphWithAdapters> {
const statics = [new GitAdapter(), new GithubAdapter()];
return Promise.all(statics.map((a) => a.load(repo))).then((adapters) => {
const graph = Graph.merge(adapters.map((x) => x.graph()));
return {graph, adapters};
});
}

View File

@ -0,0 +1,314 @@
// @flow
import {
StateTransitionMachine,
initialState,
type AppState,
type GraphWithAdapters,
} from "./state";
import {Graph} from "../../core/graph";
import {makeRepo, type Repo} from "../../core/repo";
import {type EdgeEvaluator} from "../../core/attribution/pagerank";
import type {
PagerankNodeDecomposition,
PagerankOptions,
} from "../../core/attribution/pagerank";
describe("app/credExplorer/state", () => {
function example(startingState: AppState) {
const stateContainer = {appState: startingState};
const getState = () => stateContainer.appState;
const setState = (appState) => {
stateContainer.appState = appState;
};
const loadGraphMock: (repo: Repo) => Promise<GraphWithAdapters> = jest.fn();
const pagerankMock: (
Graph,
EdgeEvaluator,
PagerankOptions
) => Promise<PagerankNodeDecomposition> = jest.fn();
const stm = new StateTransitionMachine(
getState,
setState,
loadGraphMock,
pagerankMock
);
return {getState, stm, loadGraphMock, pagerankMock};
}
function initialized(substate): AppState {
return {
type: "INITIALIZED",
repo: makeRepo("foo", "bar"),
edgeEvaluator: edgeEvaluator(),
substate,
};
}
function readyToLoadGraph(): AppState {
return initialized({type: "READY_TO_LOAD_GRAPH", loading: "NOT_LOADING"});
}
function readyToRunPagerank(): AppState {
return initialized({
type: "READY_TO_RUN_PAGERANK",
loading: "NOT_LOADING",
graphWithAdapters: graphWithAdapters(),
});
}
function pagerankEvaluated(): AppState {
return initialized({
type: "PAGERANK_EVALUATED",
graphWithAdapters: graphWithAdapters(),
pagerankNodeDecomposition: pagerankNodeDecomposition(),
loading: "NOT_LOADING",
});
}
function edgeEvaluator(): EdgeEvaluator {
return (_unused_Edge) => ({toWeight: 3, froWeight: 4});
}
function graphWithAdapters(): GraphWithAdapters {
return {graph: new Graph(), adapters: []};
}
function pagerankNodeDecomposition() {
return new Map();
}
function getSubstate(state: AppState) {
if (state.type !== "INITIALIZED") {
throw new Error("Tried to get invalid substate");
}
return state.substate;
}
function loading(state: AppState) {
if (
state.type !== "INITIALIZED" ||
state.substate.type === "PAGERANK_EVALUATED"
) {
throw new Error("Tried to get invalid loading");
}
return state.substate.loading;
}
describe("setRepo", () => {
describe("in UNINITIALIZED", () => {
it("stays UNINITIALIZED if edge evaluator not set", () => {
const {getState, stm} = example(initialState());
const repo = makeRepo("foo", "bar");
stm.setRepo(repo);
const state = getState();
expect(state.type).toBe("UNINITIALIZED");
expect(state.repo).toEqual(repo);
});
it("transitions to INITIALIZED if an edge evaluator was set", () => {
const {getState, stm} = example(initialState());
stm.setEdgeEvaluator(edgeEvaluator());
const repo = makeRepo("foo", "bar");
stm.setRepo(repo);
const state = getState();
expect(state.type).toBe("INITIALIZED");
expect(state.repo).toEqual(repo);
});
});
describe("in INITIALIZED", () => {
it("stays in READY_TO_LOAD_GRAPH with new repo", () => {
const {getState, stm} = example(readyToLoadGraph());
const repo = makeRepo("zoink", "zod");
stm.setRepo(repo);
const state = getState();
expect(getSubstate(state).type).toBe("READY_TO_LOAD_GRAPH");
expect(state.repo).toEqual(repo);
});
it("transitions READY_TO_RUN_PAGERANK to READY_TO_LOAD_GRAPH with new repo", () => {
const {getState, stm} = example(readyToRunPagerank());
const repo = makeRepo("zoink", "zod");
stm.setRepo(repo);
const state = getState();
expect(getSubstate(state).type).toBe("READY_TO_LOAD_GRAPH");
expect(state.repo).toEqual(repo);
});
it("transitions PAGERANK_EVALUATED to READY_TO_LOAD_GRAPH with new repo", () => {
const {getState, stm} = example(pagerankEvaluated());
const repo = makeRepo("zoink", "zod");
stm.setRepo(repo);
const state = getState();
expect(getSubstate(state).type).toBe("READY_TO_LOAD_GRAPH");
expect(state.repo).toEqual(repo);
});
});
});
describe("setEdgeEvaluator", () => {
describe("in UNINITIALIZED", () => {
it("sets ee without transitioning to INITIALIZE if repo not set", () => {
const {getState, stm} = example(initialState());
const ee = edgeEvaluator();
stm.setEdgeEvaluator(ee);
const state = getState();
expect(state.type).toBe("UNINITIALIZED");
expect(state.edgeEvaluator).toBe(ee);
});
it("triggers transition to INITIALIZED if repo was set", () => {
const {getState, stm} = example(initialState());
stm.setRepo(makeRepo("foo", "zod"));
const ee = edgeEvaluator();
stm.setEdgeEvaluator(ee);
const state = getState();
expect(state.type).toBe("INITIALIZED");
expect(state.edgeEvaluator).toBe(ee);
});
});
describe("in INITIALIZED", () => {
it("does not transition READY_TO_LOAD_GRAPH", () => {
const {getState, stm} = example(readyToLoadGraph());
const ee = edgeEvaluator();
stm.setEdgeEvaluator(ee);
const state = getState();
expect(getSubstate(state).type).toBe("READY_TO_LOAD_GRAPH");
expect(state.edgeEvaluator).toBe(ee);
});
it("does not transition READY_TO_RUN_PAGERANK", () => {
const {getState, stm} = example(readyToRunPagerank());
const ee = edgeEvaluator();
stm.setEdgeEvaluator(ee);
const state = getState();
expect(getSubstate(state).type).toBe("READY_TO_RUN_PAGERANK");
expect(state.edgeEvaluator).toBe(ee);
});
it("does not transition PAGERANK_EVALUATED", () => {
const {getState, stm} = example(pagerankEvaluated());
const ee = edgeEvaluator();
stm.setEdgeEvaluator(ee);
const state = getState();
expect(getSubstate(state).type).toBe("PAGERANK_EVALUATED");
expect(state.edgeEvaluator).toBe(ee);
});
});
});
describe("loadGraph", () => {
it("can only be called when READY_TO_LOAD_GRAPH", async () => {
const badStates = [
initialState(),
readyToRunPagerank(),
pagerankEvaluated(),
];
for (const b of badStates) {
const {stm} = example(b);
await expect(stm.loadGraph()).rejects.toThrow("incorrect state");
}
});
it("immediately sets loading status", () => {
const {getState, stm} = example(readyToLoadGraph());
expect(loading(getState())).toBe("NOT_LOADING");
stm.loadGraph();
expect(loading(getState())).toBe("LOADING");
expect(getSubstate(getState()).type).toBe("READY_TO_LOAD_GRAPH");
});
it("transitions to READY_TO_RUN_PAGERANK on success", async () => {
const {getState, stm, loadGraphMock} = example(readyToLoadGraph());
const gwa = graphWithAdapters();
loadGraphMock.mockResolvedValue(gwa);
await stm.loadGraph();
const state = getState();
const substate = getSubstate(state);
expect(loading(state)).toBe("NOT_LOADING");
expect(substate.type).toBe("READY_TO_RUN_PAGERANK");
if (substate.type !== "READY_TO_RUN_PAGERANK") {
throw new Error("Impossible");
}
expect(substate.graphWithAdapters).toBe(gwa);
});
it("does not transition if another transition happens first", async () => {
const {getState, stm, loadGraphMock} = example(readyToLoadGraph());
const swappedRepo = makeRepo("too", "fast");
loadGraphMock.mockImplementation(
() =>
new Promise((resolve) => {
stm.setRepo(swappedRepo);
resolve(graphWithAdapters());
})
);
await stm.loadGraph();
const state = getState();
const substate = getSubstate(state);
expect(loading(state)).toBe("NOT_LOADING");
expect(substate.type).toBe("READY_TO_LOAD_GRAPH");
expect(state.repo).toBe(swappedRepo);
});
it("sets loading state FAILED on reject", async () => {
const {getState, stm, loadGraphMock} = example(readyToLoadGraph());
const error = new Error("Oh no!");
// $ExpectFlowError
console.error = jest.fn();
loadGraphMock.mockRejectedValue(error);
await stm.loadGraph();
const state = getState();
const substate = getSubstate(state);
expect(loading(state)).toBe("FAILED");
expect(substate.type).toBe("READY_TO_LOAD_GRAPH");
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(error);
});
});
describe("runPagerank", () => {
it("can only be called when READY_TO_RUN_PAGERANK or PAGERANK_EVALUATED", async () => {
const badStates = [initialState(), readyToLoadGraph()];
for (const b of badStates) {
const {stm} = example(b);
await expect(stm.runPagerank()).rejects.toThrow("incorrect state");
}
});
it("can be run when READY_TO_RUN_PAGERANK or PAGERANK_EVALUATED", async () => {
const goodStates = [readyToRunPagerank(), pagerankEvaluated()];
for (const g of goodStates) {
const {stm, getState, pagerankMock} = example(g);
const pnd = pagerankNodeDecomposition();
pagerankMock.mockResolvedValue(pnd);
await stm.runPagerank();
const state = getState();
const substate = getSubstate(state);
if (substate.type !== "PAGERANK_EVALUATED") {
throw new Error("Impossible");
}
expect(substate.type).toBe("PAGERANK_EVALUATED");
expect(substate.pagerankNodeDecomposition).toBe(pnd);
}
});
it("immediately sets loading status", () => {
const {getState, stm} = example(readyToRunPagerank());
expect(loading(getState())).toBe("NOT_LOADING");
stm.runPagerank();
expect(loading(getState())).toBe("LOADING");
});
it("does not transition if another transition happens first", async () => {
const {getState, stm, pagerankMock} = example(readyToRunPagerank());
const swappedRepo = makeRepo("too", "fast");
pagerankMock.mockImplementation(
() =>
new Promise((resolve) => {
stm.setRepo(swappedRepo);
resolve(graphWithAdapters());
})
);
await stm.runPagerank();
const state = getState();
const substate = getSubstate(state);
expect(loading(state)).toBe("NOT_LOADING");
expect(substate.type).toBe("READY_TO_LOAD_GRAPH");
expect(state.repo).toBe(swappedRepo);
});
it("sets loading state FAILED on reject", async () => {
const {getState, stm, pagerankMock} = example(readyToRunPagerank());
const error = new Error("Oh no!");
// $ExpectFlowError
console.error = jest.fn();
pagerankMock.mockRejectedValue(error);
await stm.runPagerank();
const state = getState();
const substate = getSubstate(state);
expect(loading(state)).toBe("FAILED");
expect(substate.type).toBe("READY_TO_RUN_PAGERANK");
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(error);
});
});
});