Use AppStateTransitionMachine to drive App (#583)

Pull #579 reifies the cred explorer state as an explicit state
transition machine, with a well-tested implementation. This pull
re-writes `credExplorer/App.js` to use that implementation, and thoroughly
tests it.

The result is that `credExplorer/App.js` has much simpler code
(because it just binds the rendered components to the state machine),
and is much more thoroughly tested.

To ensure easy testability of the `App` class, it was refactored so that
the module exports a factory function which takes a method for creating
the `AppStateTransitionMachine` and returns an `App` class. This ensures
that in test code, we can easily mock the state transition machine. This
had no effect on external callers, since the higher-level `<AppPage>`
class, which already wraps over `LocalStore` choice, was already the
preferred call site.

I also added a loading indicator component, which presently displays a
status text corresponding to the state, such as "Loading graph...", or
"Error while running PageRank". This way, there is always some user
feedback during loading states (which could take a while).

Test plan:
Visual inspection, and the very thorough included unit tests.
This commit is contained in:
Dandelion Mané 2018-08-02 20:16:55 -07:00 committed by GitHub
parent 20c80c3390
commit 10f704ebd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 433 additions and 186 deletions

View File

@ -6,19 +6,15 @@ import type {LocalStore} from "../localStore";
import CheckedLocalStore from "../checkedLocalStore";
import BrowserLocalStore from "../browserLocalStore";
import {StaticPluginAdapter as GithubAdapter} from "../../plugins/github/pluginAdapter";
import {StaticPluginAdapter as GitAdapter} from "../../plugins/git/pluginAdapter";
import {Graph} from "../../core/graph";
import {pagerank} from "../../core/attribution/pagerank";
import {PagerankTable} from "./PagerankTable";
import type {DynamicPluginAdapter} from "../pluginAdapter";
import {type EdgeEvaluator} from "../../core/attribution/pagerank";
import {WeightConfig} from "./WeightConfig";
import type {PagerankNodeDecomposition} from "../../core/attribution/pagerankNodeDecomposition";
import RepositorySelect from "./RepositorySelect";
import type {Repo} from "../../core/repo";
import * as NullUtil from "../../util/null";
import {
createStateTransitionMachine,
type AppState,
type StateTransitionMachineInterface,
initialState,
} from "./state";
export default class AppPage extends React.Component<{||}> {
static _LOCAL_STORE = new CheckedLocalStore(
@ -29,109 +25,139 @@ export default class AppPage extends React.Component<{||}> {
);
render() {
const App = createApp(createStateTransitionMachine);
return <App localStore={AppPage._LOCAL_STORE} />;
}
}
type Props = {|+localStore: LocalStore|};
type State = {
selectedRepo: ?Repo,
data: {|
graphWithAdapters: ?{|
+graph: Graph,
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
|},
+pnd: ?PagerankNodeDecomposition,
|},
edgeEvaluator: ?EdgeEvaluator,
};
type State = {|
appState: AppState,
|};
const MAX_ENTRIES_PER_LIST = 100;
export function createApp(
createSTM: (
getState: () => AppState,
setState: (AppState) => void
) => StateTransitionMachineInterface
) {
return class App extends React.Component<Props, State> {
stateTransitionMachine: StateTransitionMachineInterface;
export class App extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
selectedRepo: null,
data: {graphWithAdapters: null, pnd: null},
edgeEvaluator: null,
};
}
constructor(props: Props) {
super(props);
this.state = {
appState: initialState(),
};
this.stateTransitionMachine = createSTM(
() => this.state.appState,
(appState) => this.setState({appState})
);
}
render() {
const {localStore} = this.props;
const {edgeEvaluator, selectedRepo} = this.state;
const {graphWithAdapters, pnd} = this.state.data;
return (
<div style={{maxWidth: 900, margin: "0 auto", padding: "0 10px"}}>
<div>
render() {
const {localStore} = this.props;
const {appState} = this.state;
const subType =
appState.type === "INITIALIZED" ? appState.substate.type : null;
const loadingState =
appState.type === "INITIALIZED" ? appState.substate.loading : null;
let pagerankTable;
if (
appState.type === "INITIALIZED" &&
appState.substate.type === "PAGERANK_EVALUATED"
) {
const adapters = appState.substate.graphWithAdapters.adapters;
const pnd = appState.substate.pagerankNodeDecomposition;
pagerankTable = (
<PagerankTable
adapters={adapters}
pnd={pnd}
maxEntriesPerList={100}
/>
);
}
return (
<div style={{maxWidth: 900, margin: "0 auto", padding: "0 10px"}}>
<div style={{marginBottom: 10}}>
<RepositorySelect
localStore={localStore}
onChange={(selectedRepo) => this.setState({selectedRepo})}
onChange={(repo) => this.stateTransitionMachine.setRepo(repo)}
/>
</div>
<button
disabled={selectedRepo == null}
onClick={() => this.loadData()}
disabled={subType !== "READY_TO_LOAD_GRAPH"}
onClick={() => this.stateTransitionMachine.loadGraph()}
>
Load data
Load graph
</button>
<button
disabled={graphWithAdapters == null || edgeEvaluator == null}
onClick={() => {
if (graphWithAdapters == null || edgeEvaluator == null) {
throw new Error("Unexpected null value");
}
const {graph} = graphWithAdapters;
pagerank(graph, edgeEvaluator, {
verbose: true,
}).then((pnd) => {
const data = {graphWithAdapters, pnd};
// In case a new graph was loaded while waiting for
// PageRank.
const stomped =
this.state.data.graphWithAdapters &&
this.state.data.graphWithAdapters.graph !== graph;
if (!stomped) {
this.setState({data});
}
});
}}
disabled={
!(
(subType === "READY_TO_RUN_PAGERANK" ||
subType === "PAGERANK_EVALUATED") &&
loadingState !== "LOADING"
)
}
onClick={() => this.stateTransitionMachine.runPagerank()}
>
Run PageRank
</button>
<WeightConfig
localStore={localStore}
onChange={(ee) => this.setState({edgeEvaluator: ee})}
/>
<PagerankTable
adapters={NullUtil.map(graphWithAdapters, (x) => x.adapters)}
pnd={pnd}
maxEntriesPerList={MAX_ENTRIES_PER_LIST}
onChange={(ee) => this.stateTransitionMachine.setEdgeEvaluator(ee)}
/>
<LoadingIndicator appState={this.state.appState} />
{pagerankTable}
</div>
</div>
);
}
};
}
export class LoadingIndicator extends React.PureComponent<{|
+appState: AppState,
|}> {
render() {
return (
<span style={{paddingLeft: 10}}>{loadingText(this.props.appState)}</span>
);
}
}
loadData() {
const {selectedRepo} = this.state;
if (selectedRepo == null) {
throw new Error(`Impossible`);
export function loadingText(state: AppState) {
switch (state.type) {
case "UNINITIALIZED": {
return "Initializing...";
}
const statics = [new GithubAdapter(), new GitAdapter()];
Promise.all(statics.map((a) => a.load(selectedRepo))).then((adapters) => {
const graph = Graph.merge(adapters.map((x) => x.graph()));
const data = {
graphWithAdapters: {
graph,
adapters,
},
pnd: null,
};
this.setState({data});
});
case "INITIALIZED": {
switch (state.substate.type) {
case "READY_TO_LOAD_GRAPH": {
return {
LOADING: "Loading graph...",
NOT_LOADING: "Ready to load graph",
FAILED: "Error while loading graph",
}[state.substate.loading];
}
case "READY_TO_RUN_PAGERANK": {
return {
LOADING: "Running PageRank...",
NOT_LOADING: "Ready to run PageRank",
FAILED: "Error while running PageRank",
}[state.substate.loading];
}
case "PAGERANK_EVALUATED": {
return {
LOADING: "Re-running PageRank...",
NOT_LOADING: "",
FAILED: "Error while running PageRank",
}[state.substate.loading];
}
default:
throw new Error((state.substate.type: empty));
}
}
default:
throw new Error((state.type: empty));
}
}

View File

@ -1,114 +1,335 @@
// @flow
import React from "react";
import {shallow} from "enzyme";
import {Graph} from "../../core/graph";
import {makeRepo} from "../../core/repo";
import testLocalStore from "../testLocalStore";
import {pagerank} from "../../core/attribution/pagerank";
import {App} from "./App";
import {Graph, NodeAddress, EdgeAddress} from "../../core/graph";
import RepositorySelect from "./RepositorySelect";
import {PagerankTable} from "./PagerankTable";
import {WeightConfig} from "./WeightConfig";
import {createApp, LoadingIndicator} from "./App";
import {initialState} from "./state";
require("../testUtil").configureEnzyme();
require("../testUtil").configureAphrodite();
function example() {
const graph = new Graph();
const nodes = {
fooAlpha: NodeAddress.fromParts(["foo", "a", "1"]),
fooBeta: NodeAddress.fromParts(["foo", "b", "2"]),
bar1: NodeAddress.fromParts(["bar", "a", "1"]),
bar2: NodeAddress.fromParts(["bar", "2"]),
xox: NodeAddress.fromParts(["xox"]),
empty: NodeAddress.empty,
};
Object.values(nodes).forEach((n) => graph.addNode((n: any)));
function addEdge(parts, src, dst) {
const edge = {address: EdgeAddress.fromParts(parts), src, dst};
graph.addEdge(edge);
}
addEdge(["a"], nodes.fooAlpha, nodes.fooBeta);
addEdge(["b"], nodes.fooAlpha, nodes.bar1);
addEdge(["c"], nodes.fooAlpha, nodes.xox);
addEdge(["d"], nodes.bar1, nodes.bar1);
addEdge(["e"], nodes.bar1, nodes.xox);
addEdge(["e'"], nodes.bar1, nodes.xox);
const adapters = [
{
name: () => "foo",
graph: () => {
throw new Error("unused");
},
renderer: () => ({
nodeDescription: (x) => `foo: ${NodeAddress.toString(x)}`,
}),
nodePrefix: () => NodeAddress.fromParts(["foo"]),
nodeTypes: () => [
{name: "alpha", prefix: NodeAddress.fromParts(["foo", "a"])},
{name: "beta", prefix: NodeAddress.fromParts(["foo", "b"])},
],
},
{
name: () => "bar",
graph: () => {
throw new Error("unused");
},
renderer: () => ({
nodeDescription: (x) => `bar: ${NodeAddress.toString(x)}`,
}),
nodePrefix: () => NodeAddress.fromParts(["bar"]),
nodeTypes: () => [
{name: "alpha", prefix: NodeAddress.fromParts(["bar", "a"])},
],
},
{
name: () => "xox",
graph: () => {
throw new Error("unused");
},
renderer: () => ({
nodeDescription: (_unused_arg) => `xox node!`,
}),
nodePrefix: () => NodeAddress.fromParts(["xox"]),
nodeTypes: () => [],
},
{
name: () => "unused",
graph: () => {
throw new Error("unused");
},
renderer: () => {
throw new Error("Impossible!");
},
nodePrefix: () => NodeAddress.fromParts(["unused"]),
nodeTypes: () => [],
},
];
const pagerankResult = pagerank(graph, (_unused_Edge) => ({
toWeight: 1,
froWeight: 1,
}));
return {adapters, nodes, graph, pagerankResult};
}
describe("app/credExplorer/App", () => {
it("renders with clean state", () => {
shallow(<App localStore={testLocalStore()} />);
function example() {
let setState, getState;
const setRepo = jest.fn();
const setEdgeEvaluator = jest.fn();
const loadGraph = jest.fn();
const runPagerank = jest.fn();
const localStore = testLocalStore();
function createMockSTM(_getState, _setState) {
setState = _setState;
getState = _getState;
return {
setRepo,
setEdgeEvaluator,
loadGraph,
runPagerank,
};
}
const App = createApp(createMockSTM);
const el = shallow(<App localStore={localStore} />);
if (setState == null || getState == null) {
throw new Error("Initialization problems");
}
return {
el,
setState,
getState,
setRepo,
setEdgeEvaluator,
loadGraph,
runPagerank,
localStore,
};
}
function createEvaluator() {
return function(_unused_edge) {
return {toWeight: 1, froWeight: 1};
};
}
function initialized(substate) {
return {
type: "INITIALIZED",
repo: makeRepo("foo", "bar"),
edgeEvaluator: createEvaluator(),
substate,
};
}
const exampleStates = {
uninitialized: initialState,
readyToLoadGraph: (loadingState) => {
return () =>
initialized({
type: "READY_TO_LOAD_GRAPH",
loading: loadingState,
});
},
readyToRunPagerank: (loadingState) => {
return () =>
initialized({
type: "READY_TO_RUN_PAGERANK",
loading: loadingState,
graphWithAdapters: {graph: new Graph(), adapters: []},
});
},
pagerankEvaluated: (loadingState) => {
return () =>
initialized({
type: "PAGERANK_EVALUATED",
loading: loadingState,
graphWithAdapters: {graph: new Graph(), adapters: []},
pagerankNodeDecomposition: new Map(),
});
},
};
it("getState is wired properly", () => {
const {getState, el} = example();
expect(el.state().appState).toBe(getState());
});
it("renders with graph and adapters set", () => {
const app = shallow(<App localStore={testLocalStore()} />);
const {graph, adapters} = example();
const data = {graph, adapters, pagerankResult: null};
app.setState({data});
it("setState is wired properly", () => {
const {setState, el} = example();
expect(initialState()).not.toBe(initialState()); // sanity check
const newState = initialState();
setState(newState);
expect(el.state().appState).toBe(newState);
});
it("renders with graph and adapters and pagerankResult", () => {
const app = shallow(<App localStore={testLocalStore()} />);
const {graph, adapters, pagerankResult} = example();
const data = {graph, adapters, pagerankResult};
app.setState({data});
it("localStore is wired properly", () => {
const {el, localStore} = example();
expect(el.instance().props.localStore).toBe(localStore);
});
describe("when in state:", () => {
function testRepositorySelect(stateFn) {
it("creates a working RepositorySelect", () => {
const {el, setRepo, setState, localStore} = example();
setState(stateFn());
const rs = el.find(RepositorySelect);
const newRepo = makeRepo("zoo", "zod");
rs.props().onChange(newRepo);
expect(setRepo).toHaveBeenCalledWith(newRepo);
expect(rs.props().localStore).toBe(localStore);
});
}
function testWeightConfig(stateFn) {
it("creates a working WeightConfig", () => {
const {el, setEdgeEvaluator, setState, localStore} = example();
setState(stateFn());
const wc = el.find(WeightConfig);
const ee = createEvaluator();
wc.props().onChange(ee);
expect(setEdgeEvaluator).toHaveBeenCalledWith(ee);
expect(wc.props().localStore).toBe(localStore);
});
}
function testGraphButton(stateFn, {disabled}) {
const adjective = disabled ? "disabled" : "working";
it(`has a ${adjective} load graph button`, () => {
const {el, loadGraph, setState} = example();
setState(stateFn());
el.update();
const button = el.findWhere(
(b) => b.text() === "Load graph" && b.is("button")
);
if (disabled) {
expect(button.props().disabled).toBe(true);
} else {
expect(button.props().disabled).toBe(false);
button.simulate("click");
expect(loadGraph).toBeCalledTimes(1);
}
});
}
function testPagerankButton(stateFn, {disabled}) {
const adjective = disabled ? "disabled" : "working";
it(`has a ${adjective} run PageRank button`, () => {
const {el, runPagerank, setState} = example();
setState(stateFn());
el.update();
const button = el.findWhere(
(b) => b.text() === "Run PageRank" && b.is("button")
);
if (disabled) {
expect(button.props().disabled).toBe(true);
} else {
expect(button.props().disabled).toBe(false);
button.simulate("click");
expect(runPagerank).toBeCalledTimes(1);
}
});
}
function testPagerankTable(stateFn, present: boolean) {
const verb = present ? "has" : "doesn't have";
it(`${verb} a PagerankTable`, () => {
const {el, setState} = example();
const state = stateFn();
setState(state);
el.update();
const prt = el.find(PagerankTable);
if (present) {
expect(prt).toHaveLength(1);
if (
state.type !== "INITIALIZED" ||
state.substate.type !== "PAGERANK_EVALUATED"
) {
throw new Error("This test case is impossible to satisfy");
}
const adapters = state.substate.graphWithAdapters.adapters;
const pnd = state.substate.pagerankNodeDecomposition;
expect(prt.props().adapters).toBe(adapters);
expect(prt.props().pnd).toBe(pnd);
} else {
expect(prt).toHaveLength(0);
}
});
}
function testLoadingIndicator(stateFn) {
it("has a LoadingIndicator", () => {
const {el, setState} = example();
const state = stateFn();
setState(state);
el.update();
const li = el.find(LoadingIndicator);
expect(li.props().appState).toEqual(state);
});
}
function stateTestSuite(
suiteName,
stateFn,
{loadGraphDisabled, runPagerankDisabled, hasPagerankTable}
) {
describe(suiteName, () => {
testWeightConfig(stateFn);
testRepositorySelect(stateFn);
testGraphButton(stateFn, {disabled: loadGraphDisabled});
testPagerankButton(stateFn, {disabled: runPagerankDisabled});
testPagerankTable(stateFn, hasPagerankTable);
testLoadingIndicator(stateFn);
});
}
stateTestSuite("UNINITIALIZED", exampleStates.uninitialized, {
loadGraphDisabled: true,
runPagerankDisabled: true,
hasPagerankTable: false,
});
describe("READY_TO_LOAD_GRAPH", () => {
for (const loadingState of ["LOADING", "NOT_LOADING", "FAILED"]) {
stateTestSuite(
loadingState,
exampleStates.readyToLoadGraph(loadingState),
{
loadGraphDisabled: false,
runPagerankDisabled: true,
hasPagerankTable: false,
}
);
}
});
describe("READY_TO_RUN_PAGERANK", () => {
for (const loadingState of ["LOADING", "NOT_LOADING", "FAILED"]) {
stateTestSuite(
loadingState,
exampleStates.readyToRunPagerank(loadingState),
{
loadGraphDisabled: true,
runPagerankDisabled: loadingState === "LOADING",
hasPagerankTable: false,
}
);
}
});
describe("PAGERANK_EVALUATED", () => {
for (const loadingState of ["LOADING", "NOT_LOADING", "FAILED"]) {
stateTestSuite(
loadingState,
exampleStates.pagerankEvaluated(loadingState),
{
loadGraphDisabled: true,
runPagerankDisabled: loadingState === "LOADING",
hasPagerankTable: true,
}
);
}
});
});
describe("LoadingIndicator", () => {
describe("displays the right status text when ", () => {
function testStatusText(stateName, stateFn, expectedText) {
it(stateName, () => {
const el = shallow(<LoadingIndicator appState={stateFn()} />);
expect(el.text()).toEqual(expectedText);
});
}
testStatusText(
"initializing",
exampleStates.uninitialized,
"Initializing..."
);
testStatusText(
"ready to load graph",
exampleStates.readyToLoadGraph("NOT_LOADING"),
"Ready to load graph"
);
testStatusText(
"loading graph",
exampleStates.readyToLoadGraph("LOADING"),
"Loading graph..."
);
testStatusText(
"failed to load graph",
exampleStates.readyToLoadGraph("FAILED"),
"Error while loading graph"
);
testStatusText(
"ready to run pagerank",
exampleStates.readyToRunPagerank("NOT_LOADING"),
"Ready to run PageRank"
);
testStatusText(
"running pagerank",
exampleStates.readyToRunPagerank("LOADING"),
"Running PageRank..."
);
testStatusText(
"pagerank failed",
exampleStates.readyToRunPagerank("FAILED"),
"Error while running PageRank"
);
testStatusText(
"pagerank succeeded",
exampleStates.pagerankEvaluated("NOT_LOADING"),
""
);
testStatusText(
"re-running pagerank",
exampleStates.pagerankEvaluated("LOADING"),
"Re-running PageRank..."
);
testStatusText(
"re-running pagerank failed",
exampleStates.pagerankEvaluated("FAILED"),
"Error while running PageRank"
);
});
});
});