mirror of
https://github.com/status-im/sourcecred.git
synced 2025-02-26 11:15:17 +00:00
Split PagerankTable into smaller files (#630)
PagerankTable is getting a bit unwieldy, especially as #502 will need to add a new pair of components. This commit splits the erstwise PagerankTable.js into four files: - `pagerankTable/shared`, shared utils and types - `pagerankTable/Node`, the `NodeRow` and `NodeRowList` - `pagerankTable/Connection`, the `ConnectionRow`, `ConnectionRowList`, and `ConnectionView` - `pagerankTable/Table`, the `PagerankTable` itself This commit makes no logical changes; it is purely a reorganization. Test plan: `yarn test`
This commit is contained in:
parent
dc13d460da
commit
74e00b0bfd
@ -6,7 +6,7 @@ import type {LocalStore} from "../localStore";
|
||||
import CheckedLocalStore from "../checkedLocalStore";
|
||||
import BrowserLocalStore from "../browserLocalStore";
|
||||
|
||||
import {PagerankTable} from "./PagerankTable";
|
||||
import {PagerankTable} from "./pagerankTable/Table";
|
||||
import {WeightConfig} from "./WeightConfig";
|
||||
import RepositorySelect from "./RepositorySelect";
|
||||
import {
|
||||
|
@ -8,7 +8,7 @@ import {makeRepo} from "../../core/repo";
|
||||
import testLocalStore from "../testLocalStore";
|
||||
|
||||
import RepositorySelect from "./RepositorySelect";
|
||||
import {PagerankTable} from "./PagerankTable";
|
||||
import {PagerankTable} from "./pagerankTable/Table";
|
||||
import {WeightConfig} from "./WeightConfig";
|
||||
import {createApp, LoadingIndicator} from "./App";
|
||||
import {initialState} from "./state";
|
||||
|
@ -1,381 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import sortBy from "lodash.sortby";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
type EdgeAddressT,
|
||||
type NodeAddressT,
|
||||
NodeAddress,
|
||||
} from "../../core/graph";
|
||||
import type {
|
||||
PagerankNodeDecomposition,
|
||||
ScoredConnection,
|
||||
} from "../../core/attribution/pagerankNodeDecomposition";
|
||||
import type {Connection} from "../../core/attribution/graphToMarkovChain";
|
||||
import {
|
||||
type DynamicPluginAdapter,
|
||||
dynamicDispatchByNode,
|
||||
dynamicDispatchByEdge,
|
||||
findEdgeType,
|
||||
} from "../pluginAdapter";
|
||||
import * as NullUtil from "../../util/null";
|
||||
|
||||
export function nodeDescription(
|
||||
address: NodeAddressT,
|
||||
adapters: $ReadOnlyArray<DynamicPluginAdapter>
|
||||
): string {
|
||||
const adapter = dynamicDispatchByNode(adapters, address);
|
||||
try {
|
||||
return adapter.nodeDescription(address);
|
||||
} catch (e) {
|
||||
const result = NodeAddress.toString(address);
|
||||
console.error(`Error getting description for ${result}: ${e.message}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function edgeVerb(
|
||||
address: EdgeAddressT,
|
||||
direction: "FORWARD" | "BACKWARD",
|
||||
adapters: $ReadOnlyArray<DynamicPluginAdapter>
|
||||
): string {
|
||||
const adapter = dynamicDispatchByEdge(adapters, address);
|
||||
const edgeType = findEdgeType(adapter.static(), address);
|
||||
return direction === "FORWARD" ? edgeType.forwardName : edgeType.backwardName;
|
||||
}
|
||||
|
||||
function scoreDisplay(score: number) {
|
||||
return score.toFixed(2);
|
||||
}
|
||||
|
||||
type SharedProps = {|
|
||||
+pnd: PagerankNodeDecomposition,
|
||||
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
|
||||
+maxEntriesPerList: number,
|
||||
|};
|
||||
|
||||
type PagerankTableProps = {|
|
||||
+pnd: PagerankNodeDecomposition,
|
||||
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
|
||||
+maxEntriesPerList: number,
|
||||
|};
|
||||
type PagerankTableState = {|topLevelFilter: NodeAddressT|};
|
||||
export class PagerankTable extends React.PureComponent<
|
||||
PagerankTableProps,
|
||||
PagerankTableState
|
||||
> {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {topLevelFilter: NodeAddress.empty};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div style={{marginTop: 10}}>
|
||||
{this.renderFilterSelect()}
|
||||
{this.renderTable()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderFilterSelect() {
|
||||
const {pnd, adapters} = this.props;
|
||||
if (pnd == null || adapters == null) {
|
||||
throw new Error("Impossible.");
|
||||
}
|
||||
|
||||
function optionGroup(adapter: DynamicPluginAdapter) {
|
||||
const header = (
|
||||
<option
|
||||
key={adapter.static().nodePrefix()}
|
||||
value={adapter.static().nodePrefix()}
|
||||
style={{fontWeight: "bold"}}
|
||||
>
|
||||
{adapter.static().name()}
|
||||
</option>
|
||||
);
|
||||
const entries = adapter
|
||||
.static()
|
||||
.nodeTypes()
|
||||
.map((type) => (
|
||||
<option key={type.prefix} value={type.prefix}>
|
||||
{"\u2003" + type.name}
|
||||
</option>
|
||||
));
|
||||
return [header, ...entries];
|
||||
}
|
||||
return (
|
||||
<label>
|
||||
<span>Filter by node type: </span>
|
||||
<select
|
||||
value={this.state.topLevelFilter}
|
||||
onChange={(e) => {
|
||||
this.setState({topLevelFilter: e.target.value});
|
||||
}}
|
||||
>
|
||||
<option value={NodeAddress.empty}>Show all</option>
|
||||
{sortBy(adapters, (a: DynamicPluginAdapter) => a.static().name()).map(
|
||||
optionGroup
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
renderTable() {
|
||||
const {pnd, adapters, maxEntriesPerList} = this.props;
|
||||
if (pnd == null || adapters == null || maxEntriesPerList == null) {
|
||||
throw new Error("Impossible.");
|
||||
}
|
||||
const topLevelFilter = this.state.topLevelFilter;
|
||||
const sharedProps = {pnd, adapters, maxEntriesPerList};
|
||||
return (
|
||||
<table
|
||||
style={{
|
||||
borderCollapse: "collapse",
|
||||
marginTop: 10,
|
||||
// If we don't subtract 1px here, then a horizontal scrollbar
|
||||
// appears in Chrome (but not Firefox). I'm not sure why.
|
||||
width: "calc(100% - 1px)",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{textAlign: "left"}}>Description</th>
|
||||
<th style={{textAlign: "right"}}>Connection</th>
|
||||
<th style={{textAlign: "right"}}>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<NodeRowList
|
||||
sharedProps={sharedProps}
|
||||
nodes={Array.from(pnd.keys()).filter((node) =>
|
||||
NodeAddress.hasPrefix(node, topLevelFilter)
|
||||
)}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type NodeRowListProps = {|
|
||||
+nodes: $ReadOnlyArray<NodeAddressT>,
|
||||
+sharedProps: SharedProps,
|
||||
|};
|
||||
|
||||
export class NodeRowList extends React.PureComponent<NodeRowListProps> {
|
||||
render() {
|
||||
const {nodes, sharedProps} = this.props;
|
||||
const {pnd, maxEntriesPerList} = sharedProps;
|
||||
return (
|
||||
<React.Fragment>
|
||||
{sortBy(nodes, (n) => -NullUtil.get(pnd.get(n)).score, (n) => n)
|
||||
.slice(0, maxEntriesPerList)
|
||||
.map((node) => (
|
||||
<NodeRow node={node} key={node} sharedProps={sharedProps} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type RowState = {|
|
||||
expanded: boolean,
|
||||
|};
|
||||
|
||||
type NodeRowProps = {|
|
||||
+node: NodeAddressT,
|
||||
+sharedProps: SharedProps,
|
||||
|};
|
||||
|
||||
export class NodeRow extends React.PureComponent<NodeRowProps, RowState> {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {expanded: false};
|
||||
}
|
||||
render() {
|
||||
const {node, sharedProps} = this.props;
|
||||
const {pnd, adapters} = sharedProps;
|
||||
const {expanded} = this.state;
|
||||
const {score} = NullUtil.get(pnd.get(node));
|
||||
return (
|
||||
<React.Fragment>
|
||||
<tr key="self">
|
||||
<td style={{display: "flex", alignItems: "flex-start"}}>
|
||||
<button
|
||||
style={{marginRight: 5}}
|
||||
onClick={() => {
|
||||
this.setState(({expanded}) => ({
|
||||
expanded: !expanded,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{expanded ? "\u2212" : "+"}
|
||||
</button>
|
||||
<span>{nodeDescription(node, adapters)}</span>
|
||||
</td>
|
||||
<td style={{textAlign: "right"}}>{"—"}</td>
|
||||
<td style={{textAlign: "right"}}>{scoreDisplay(score)}</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<ConnectionRowList
|
||||
key="children"
|
||||
depth={1}
|
||||
node={node}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectionRowListProps = {|
|
||||
+depth: number,
|
||||
+node: NodeAddressT,
|
||||
+sharedProps: SharedProps,
|
||||
|};
|
||||
|
||||
export class ConnectionRowList extends React.PureComponent<
|
||||
ConnectionRowListProps
|
||||
> {
|
||||
render() {
|
||||
const {depth, node, sharedProps} = this.props;
|
||||
const {pnd, maxEntriesPerList} = sharedProps;
|
||||
const {scoredConnections} = NullUtil.get(pnd.get(node));
|
||||
return (
|
||||
<React.Fragment>
|
||||
{scoredConnections
|
||||
.slice(0, maxEntriesPerList)
|
||||
.map((sc) => (
|
||||
<ConnectionRow
|
||||
key={JSON.stringify(sc.connection.adjacency)}
|
||||
depth={depth}
|
||||
target={node}
|
||||
scoredConnection={sc}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectionRowProps = {|
|
||||
+depth: number,
|
||||
+target: NodeAddressT,
|
||||
+scoredConnection: ScoredConnection,
|
||||
+sharedProps: SharedProps,
|
||||
|};
|
||||
|
||||
export class ConnectionRow extends React.PureComponent<
|
||||
ConnectionRowProps,
|
||||
RowState
|
||||
> {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {expanded: false};
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
sharedProps,
|
||||
target,
|
||||
depth,
|
||||
scoredConnection: {connection, source, sourceScore, connectionScore},
|
||||
} = this.props;
|
||||
const {pnd, adapters} = sharedProps;
|
||||
const {expanded} = this.state;
|
||||
const {score: targetScore} = NullUtil.get(pnd.get(target));
|
||||
const connectionProportion = connectionScore / targetScore;
|
||||
const connectionPercent = (connectionProportion * 100).toFixed(2);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<tr
|
||||
key="self"
|
||||
style={{backgroundColor: `rgba(0,143.4375,0,${1 - 0.9 ** depth})`}}
|
||||
>
|
||||
<td style={{display: "flex", alignItems: "flex-start"}}>
|
||||
<button
|
||||
style={{
|
||||
marginRight: 5,
|
||||
marginLeft: 15 * depth,
|
||||
}}
|
||||
onClick={() => {
|
||||
this.setState(({expanded}) => ({
|
||||
expanded: !expanded,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{expanded ? "\u2212" : "+"}
|
||||
</button>
|
||||
<ConnectionView connection={connection} adapters={adapters} />
|
||||
</td>
|
||||
<td style={{textAlign: "right"}}>{connectionPercent}%</td>
|
||||
<td style={{textAlign: "right"}}>{scoreDisplay(sourceScore)}</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<ConnectionRowList
|
||||
key="children"
|
||||
depth={depth + 1}
|
||||
node={source}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConnectionView extends React.PureComponent<{|
|
||||
+connection: Connection,
|
||||
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
|
||||
|}> {
|
||||
render() {
|
||||
const {connection, adapters} = this.props;
|
||||
function Badge({children}) {
|
||||
return (
|
||||
// The outer <span> acts as a strut to ensure that the badge
|
||||
// takes up a full line height, even though its text is smaller.
|
||||
<span>
|
||||
<span
|
||||
style={{
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 700,
|
||||
fontSize: "smaller",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const {adjacency} = connection;
|
||||
switch (adjacency.type) {
|
||||
case "SYNTHETIC_LOOP":
|
||||
return <Badge>synthetic loop</Badge>;
|
||||
case "IN_EDGE":
|
||||
return (
|
||||
<span>
|
||||
<Badge>
|
||||
{edgeVerb(adjacency.edge.address, "BACKWARD", adapters)}
|
||||
</Badge>{" "}
|
||||
<span>{nodeDescription(adjacency.edge.src, adapters)}</span>
|
||||
</span>
|
||||
);
|
||||
case "OUT_EDGE":
|
||||
return (
|
||||
<span>
|
||||
<Badge>
|
||||
{edgeVerb(adjacency.edge.address, "FORWARD", adapters)}
|
||||
</Badge>{" "}
|
||||
<span>{nodeDescription(adjacency.edge.dst, adapters)}</span>
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
throw new Error((adjacency.type: empty));
|
||||
}
|
||||
}
|
||||
}
|
@ -1,607 +0,0 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import {shallow} from "enzyme";
|
||||
|
||||
import type {DynamicPluginAdapter} from "../pluginAdapter";
|
||||
|
||||
import {
|
||||
PagerankTable,
|
||||
NodeRowList,
|
||||
NodeRow,
|
||||
ConnectionRowList,
|
||||
ConnectionRow,
|
||||
ConnectionView,
|
||||
} from "./PagerankTable";
|
||||
import {pagerank} from "../../core/attribution/pagerank";
|
||||
import sortBy from "lodash.sortby";
|
||||
import {type Connection} from "../../core/attribution/graphToMarkovChain";
|
||||
import * as NullUtil from "../../util/null";
|
||||
|
||||
import {
|
||||
Graph,
|
||||
type NodeAddressT,
|
||||
NodeAddress,
|
||||
EdgeAddress,
|
||||
} from "../../core/graph";
|
||||
|
||||
require("../testUtil").configureEnzyme();
|
||||
|
||||
const COLUMNS = () => ["Description", "Connection", "Score"];
|
||||
|
||||
async 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);
|
||||
return edge;
|
||||
}
|
||||
|
||||
const edges = {
|
||||
fooA: addEdge(["foo", "a"], nodes.fooAlpha, nodes.fooBeta),
|
||||
fooB: addEdge(["foo", "b"], nodes.fooAlpha, nodes.bar1),
|
||||
fooC: addEdge(["foo", "c"], nodes.fooAlpha, nodes.xox),
|
||||
barD: addEdge(["bar", "d"], nodes.bar1, nodes.bar1),
|
||||
barE: addEdge(["bar", "e"], nodes.bar1, nodes.xox),
|
||||
barF: addEdge(["bar", "f"], nodes.bar1, nodes.xox),
|
||||
};
|
||||
|
||||
const adapters: DynamicPluginAdapter[] = [
|
||||
{
|
||||
static: () => ({
|
||||
name: () => "foo",
|
||||
nodePrefix: () => NodeAddress.fromParts(["foo"]),
|
||||
edgePrefix: () => EdgeAddress.fromParts(["foo"]),
|
||||
nodeTypes: () => [
|
||||
{
|
||||
name: "alpha",
|
||||
prefix: NodeAddress.fromParts(["foo", "a"]),
|
||||
defaultWeight: 1,
|
||||
},
|
||||
{
|
||||
name: "beta",
|
||||
prefix: NodeAddress.fromParts(["foo", "b"]),
|
||||
defaultWeight: 1,
|
||||
},
|
||||
],
|
||||
edgeTypes: () => [
|
||||
{
|
||||
prefix: EdgeAddress.fromParts(["foo"]),
|
||||
forwardName: "foos",
|
||||
backwardName: "is fooed by",
|
||||
},
|
||||
],
|
||||
load: (_unused_repo) => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
}),
|
||||
graph: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
nodeDescription: (x) => `foo: ${NodeAddress.toString(x)}`,
|
||||
},
|
||||
{
|
||||
static: () => ({
|
||||
name: () => "bar",
|
||||
nodePrefix: () => NodeAddress.fromParts(["bar"]),
|
||||
edgePrefix: () => EdgeAddress.fromParts(["bar"]),
|
||||
nodeTypes: () => [
|
||||
{
|
||||
name: "alpha",
|
||||
prefix: NodeAddress.fromParts(["bar", "a"]),
|
||||
defaultWeight: 1,
|
||||
},
|
||||
],
|
||||
edgeTypes: () => [
|
||||
{
|
||||
prefix: EdgeAddress.fromParts(["bar"]),
|
||||
forwardName: "bars",
|
||||
backwardName: "is barred by",
|
||||
},
|
||||
],
|
||||
load: (_unused_repo) => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
}),
|
||||
graph: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
nodeDescription: (x) => `bar: ${NodeAddress.toString(x)}`,
|
||||
},
|
||||
{
|
||||
static: () => ({
|
||||
name: () => "xox",
|
||||
nodePrefix: () => NodeAddress.fromParts(["xox"]),
|
||||
edgePrefix: () => EdgeAddress.fromParts(["xox"]),
|
||||
nodeTypes: () => [],
|
||||
edgeTypes: () => [],
|
||||
load: (_unused_repo) => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
}),
|
||||
graph: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
nodeDescription: (_unused_arg) => `xox node!`,
|
||||
},
|
||||
{
|
||||
static: () => ({
|
||||
nodePrefix: () => NodeAddress.fromParts(["unused"]),
|
||||
edgePrefix: () => EdgeAddress.fromParts(["unused"]),
|
||||
nodeTypes: () => [],
|
||||
edgeTypes: () => [],
|
||||
name: () => "unused",
|
||||
load: (_unused_repo) => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
}),
|
||||
graph: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
nodeDescription: () => {
|
||||
throw new Error("Unused");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const pnd = await pagerank(graph, (_unused_Edge) => ({
|
||||
toWeight: 1,
|
||||
froWeight: 1,
|
||||
}));
|
||||
|
||||
return {adapters, nodes, edges, graph, pnd};
|
||||
}
|
||||
|
||||
describe("app/credExplorer/PagerankTable", () => {
|
||||
beforeEach(() => {
|
||||
// $ExpectFlowError
|
||||
console.error = jest.fn();
|
||||
// $ExpectFlowError
|
||||
console.warn = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("PagerankTable", () => {
|
||||
it("renders thead column order properly", async () => {
|
||||
const {pnd, adapters} = await example();
|
||||
const element = shallow(
|
||||
<PagerankTable pnd={pnd} adapters={adapters} maxEntriesPerList={1} />
|
||||
);
|
||||
const th = element.find("thead th");
|
||||
const columnNames = th.map((t) => t.text());
|
||||
expect(columnNames).toEqual(COLUMNS());
|
||||
});
|
||||
|
||||
describe("has a filter select", () => {
|
||||
async function setup() {
|
||||
const {pnd, adapters} = await example();
|
||||
const element = shallow(
|
||||
<PagerankTable pnd={pnd} adapters={adapters} maxEntriesPerList={1} />
|
||||
);
|
||||
const label = element.find("label");
|
||||
const options = label.find("option");
|
||||
return {pnd, adapters, element, label, options};
|
||||
}
|
||||
it("with expected label text", async () => {
|
||||
const {label} = await setup();
|
||||
const filterText = label
|
||||
.find("span")
|
||||
.first()
|
||||
.text();
|
||||
expect(filterText).toMatchSnapshot();
|
||||
});
|
||||
it("with expected option groups", async () => {
|
||||
const {options} = await setup();
|
||||
const optionsJSON = options.map((o) => ({
|
||||
valueString: NodeAddress.toString(o.prop("value")),
|
||||
style: o.prop("style"),
|
||||
text: o.text(),
|
||||
}));
|
||||
expect(optionsJSON).toMatchSnapshot();
|
||||
});
|
||||
it("with the ability to filter nodes passed to NodeRowList", async () => {
|
||||
const {element, options} = await setup();
|
||||
const option1 = options.at(1);
|
||||
const value = option1.prop("value");
|
||||
expect(value).not.toEqual(NodeAddress.empty);
|
||||
const previousNodes = element.find("NodeRowList").prop("nodes");
|
||||
expect(
|
||||
previousNodes.every((n) => NodeAddress.hasPrefix(n, value))
|
||||
).toBe(false);
|
||||
element.find("select").simulate("change", {target: {value}});
|
||||
const actualNodes = element.find("NodeRowList").prop("nodes");
|
||||
expect(actualNodes.every((n) => NodeAddress.hasPrefix(n, value))).toBe(
|
||||
true
|
||||
);
|
||||
expect(actualNodes).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("creates a NodeRowList", () => {
|
||||
async function setup() {
|
||||
const {adapters, pnd} = await example();
|
||||
const maxEntriesPerList = 1;
|
||||
const element = shallow(
|
||||
<PagerankTable
|
||||
pnd={pnd}
|
||||
adapters={adapters}
|
||||
maxEntriesPerList={maxEntriesPerList}
|
||||
/>
|
||||
);
|
||||
const nrl = element.find("NodeRowList");
|
||||
return {adapters, pnd, element, nrl, maxEntriesPerList};
|
||||
}
|
||||
it("with the correct SharedProps", async () => {
|
||||
const {nrl, adapters, pnd, maxEntriesPerList} = await setup();
|
||||
const expectedSharedProps = {adapters, pnd, maxEntriesPerList};
|
||||
expect(nrl.prop("sharedProps")).toEqual(expectedSharedProps);
|
||||
});
|
||||
it("including all nodes by default", async () => {
|
||||
const {nrl, pnd} = await setup();
|
||||
const expectedNodes = Array.from(pnd.keys());
|
||||
expect(nrl.prop("nodes")).toEqual(expectedNodes);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeRowList", () => {
|
||||
function sortedByScore(nodes: $ReadOnlyArray<NodeAddressT>, pnd) {
|
||||
return sortBy(nodes, (node) => -NullUtil.get(pnd.get(node)).score);
|
||||
}
|
||||
async function setup(maxEntriesPerList: number = 100000) {
|
||||
const {adapters, pnd} = await example();
|
||||
const nodes = sortedByScore(Array.from(pnd.keys()), pnd)
|
||||
.reverse() // ascending order!
|
||||
.filter((x) =>
|
||||
NodeAddress.hasPrefix(x, NodeAddress.fromParts(["foo"]))
|
||||
);
|
||||
expect(nodes).not.toHaveLength(0);
|
||||
expect(nodes).not.toHaveLength(1);
|
||||
expect(nodes).not.toHaveLength(pnd.size);
|
||||
const sharedProps = {adapters, pnd, maxEntriesPerList};
|
||||
const component = <NodeRowList sharedProps={sharedProps} nodes={nodes} />;
|
||||
const element = shallow(component);
|
||||
return {element, adapters, sharedProps, nodes};
|
||||
}
|
||||
it("creates `NodeRow`s with the right props", async () => {
|
||||
const {element, nodes, sharedProps} = await setup();
|
||||
const rows = element.find("NodeRow");
|
||||
expect(rows).toHaveLength(nodes.length);
|
||||
const rowNodes = rows.map((row) => row.prop("node"));
|
||||
// Check that we selected the right set of nodes. We'll check
|
||||
// order in a separate test case.
|
||||
expect(rowNodes.slice().sort()).toEqual(nodes.slice().sort());
|
||||
rows.forEach((row) => {
|
||||
expect(row.prop("sharedProps")).toEqual(sharedProps);
|
||||
});
|
||||
});
|
||||
it("creates up to `maxEntriesPerList` `NodeRow`s", async () => {
|
||||
const maxEntriesPerList = 1;
|
||||
const {element, nodes, sharedProps} = await setup(maxEntriesPerList);
|
||||
expect(nodes.length).toBeGreaterThan(maxEntriesPerList);
|
||||
const rows = element.find("NodeRow");
|
||||
expect(rows).toHaveLength(maxEntriesPerList);
|
||||
const rowNodes = rows.map((row) => row.prop("node"));
|
||||
// Should have selected the right nodes.
|
||||
expect(rowNodes).toEqual(
|
||||
sortedByScore(nodes, sharedProps.pnd).slice(0, maxEntriesPerList)
|
||||
);
|
||||
});
|
||||
it("sorts its children by score", async () => {
|
||||
const {
|
||||
element,
|
||||
nodes,
|
||||
sharedProps: {pnd},
|
||||
} = await setup();
|
||||
expect(nodes).not.toEqual(sortedByScore(nodes, pnd));
|
||||
const rows = element.find("NodeRow");
|
||||
const rowNodes = rows.map((row) => row.prop("node"));
|
||||
expect(rowNodes).toEqual(sortedByScore(rowNodes, pnd));
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeRow", () => {
|
||||
async function setup() {
|
||||
const {pnd, adapters, nodes} = await example();
|
||||
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
|
||||
const node = nodes.bar1;
|
||||
const component = <NodeRow node={node} sharedProps={sharedProps} />;
|
||||
const element = shallow(component);
|
||||
return {element, node, sharedProps};
|
||||
}
|
||||
it("renders the right number of columns", async () => {
|
||||
expect((await setup()).element.find("td")).toHaveLength(COLUMNS().length);
|
||||
});
|
||||
it("renders the node description", async () => {
|
||||
const {element} = await setup();
|
||||
const expectedDescription = 'bar: NodeAddress["bar","a","1"]';
|
||||
const descriptionColumn = COLUMNS().indexOf("Description");
|
||||
expect(descriptionColumn).not.toEqual(-1);
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(descriptionColumn)
|
||||
.find("span")
|
||||
.text()
|
||||
).toEqual(expectedDescription);
|
||||
});
|
||||
it("renders an empty connection column", async () => {
|
||||
const {element} = await setup();
|
||||
const connectionColumn = COLUMNS().indexOf("Connection");
|
||||
expect(connectionColumn).not.toEqual(-1);
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(connectionColumn)
|
||||
.text()
|
||||
).toEqual("—");
|
||||
});
|
||||
it("renders a score column with the node's score", async () => {
|
||||
const {element, sharedProps, node} = await setup();
|
||||
const {score} = NullUtil.get(sharedProps.pnd.get(node));
|
||||
const expectedScore = score.toFixed(2);
|
||||
const connectionColumn = COLUMNS().indexOf("Score");
|
||||
expect(connectionColumn).not.toEqual(-1);
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(connectionColumn)
|
||||
.text()
|
||||
).toEqual(expectedScore);
|
||||
});
|
||||
it("does not render children by default", async () => {
|
||||
const {element} = await setup();
|
||||
expect(element.find("ConnectionRowList")).toHaveLength(0);
|
||||
});
|
||||
it('has a working "expand" button', async () => {
|
||||
const {element, sharedProps, node} = await setup();
|
||||
expect(element.find("button").text()).toEqual("+");
|
||||
|
||||
element.find("button").simulate("click");
|
||||
expect(element.find("button").text()).toEqual("\u2212");
|
||||
const crl = element.find("ConnectionRowList");
|
||||
expect(crl).toHaveLength(1);
|
||||
expect(crl.prop("sharedProps")).toEqual(sharedProps);
|
||||
expect(crl.prop("depth")).toBe(1);
|
||||
expect(crl.prop("node")).toBe(node);
|
||||
|
||||
element.find("button").simulate("click");
|
||||
expect(element.find("button").text()).toEqual("+");
|
||||
expect(element.find("ConnectionRowList")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConnectionRowList", () => {
|
||||
async function setup(maxEntriesPerList: number = 100000) {
|
||||
const {adapters, pnd, nodes} = await example();
|
||||
const depth = 2;
|
||||
const node = nodes.bar1;
|
||||
const sharedProps = {adapters, pnd, maxEntriesPerList};
|
||||
const component = (
|
||||
<ConnectionRowList
|
||||
depth={depth}
|
||||
node={node}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
);
|
||||
const element = shallow(component);
|
||||
return {element, depth, node, sharedProps};
|
||||
}
|
||||
it("creates `ConnectionRow`s with the right props", async () => {
|
||||
const {element, depth, node, sharedProps} = await setup();
|
||||
const connections = NullUtil.get(sharedProps.pnd.get(node))
|
||||
.scoredConnections;
|
||||
const rows = element.find("ConnectionRow");
|
||||
expect(rows).toHaveLength(connections.length);
|
||||
const rowPropses = rows.map((row) => row.props());
|
||||
// Order should be the same as the order in the decomposition.
|
||||
expect(rowPropses).toEqual(
|
||||
connections.map((sc) => ({
|
||||
depth,
|
||||
sharedProps,
|
||||
target: node,
|
||||
scoredConnection: sc,
|
||||
}))
|
||||
);
|
||||
});
|
||||
it("limits the number of rows by `maxEntriesPerList`", async () => {
|
||||
const maxEntriesPerList = 1;
|
||||
const {element, node, sharedProps} = await setup(maxEntriesPerList);
|
||||
const connections = NullUtil.get(sharedProps.pnd.get(node))
|
||||
.scoredConnections;
|
||||
expect(connections.length).toBeGreaterThan(maxEntriesPerList);
|
||||
const rows = element.find("ConnectionRow");
|
||||
expect(rows).toHaveLength(maxEntriesPerList);
|
||||
const rowConnections = rows.map((row) => row.prop("scoredConnection"));
|
||||
// Should have selected the right nodes.
|
||||
expect(rowConnections).toEqual(connections.slice(0, maxEntriesPerList));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConnectionRow", () => {
|
||||
async function setup() {
|
||||
const {pnd, adapters, nodes} = await example();
|
||||
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
|
||||
const target = nodes.bar1;
|
||||
const {scoredConnections} = NullUtil.get(pnd.get(target));
|
||||
const alphaConnections = scoredConnections.filter(
|
||||
(sc) => sc.source === nodes.fooAlpha
|
||||
);
|
||||
expect(alphaConnections).toHaveLength(1);
|
||||
const connection = alphaConnections[0];
|
||||
const {source} = connection;
|
||||
const depth = 2;
|
||||
const component = (
|
||||
<ConnectionRow
|
||||
depth={depth}
|
||||
target={target}
|
||||
scoredConnection={connection}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
);
|
||||
const element = shallow(component);
|
||||
return {element, depth, target, source, connection, sharedProps};
|
||||
}
|
||||
it("renders the right number of columns", async () => {
|
||||
expect((await setup()).element.find("td")).toHaveLength(COLUMNS().length);
|
||||
});
|
||||
it("has proper depth-based styling", async () => {
|
||||
const {element} = await setup();
|
||||
expect({
|
||||
buttonStyle: element.find("button").prop("style"),
|
||||
trStyle: element.find("tr").prop("style"),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
it("renders the source view", async () => {
|
||||
const {element, sharedProps, connection} = await setup();
|
||||
const descriptionColumn = COLUMNS().indexOf("Description");
|
||||
expect(descriptionColumn).not.toEqual(-1);
|
||||
const view = element
|
||||
.find("td")
|
||||
.at(descriptionColumn)
|
||||
.find("ConnectionView");
|
||||
expect(view).toHaveLength(1);
|
||||
expect(view.props()).toEqual({
|
||||
adapters: sharedProps.adapters,
|
||||
connection: connection.connection,
|
||||
});
|
||||
});
|
||||
it("renders the connection percentage", async () => {
|
||||
const {element, connection, sharedProps, target} = await setup();
|
||||
const connectionColumn = COLUMNS().indexOf("Connection");
|
||||
expect(connectionColumn).not.toEqual(-1);
|
||||
const proportion =
|
||||
connection.connectionScore /
|
||||
NullUtil.get(sharedProps.pnd.get(target)).score;
|
||||
expect(proportion).toBeGreaterThan(0.0);
|
||||
expect(proportion).toBeLessThan(1.0);
|
||||
const expectedText = (proportion * 100).toFixed(2) + "%";
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(connectionColumn)
|
||||
.text()
|
||||
).toEqual(expectedText);
|
||||
});
|
||||
it("renders a score column with the source's score", async () => {
|
||||
const {element, connection} = await setup();
|
||||
const expectedScore = connection.sourceScore.toFixed(2);
|
||||
const connectionColumn = COLUMNS().indexOf("Score");
|
||||
expect(connectionColumn).not.toEqual(-1);
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(connectionColumn)
|
||||
.text()
|
||||
).toEqual(expectedScore);
|
||||
});
|
||||
it("does not render children by default", async () => {
|
||||
const {element} = await setup();
|
||||
expect(element.find("ConnectionRowList")).toHaveLength(0);
|
||||
});
|
||||
it('has a working "expand" button', async () => {
|
||||
const {element, depth, sharedProps, source} = await setup();
|
||||
expect(element.find("button").text()).toEqual("+");
|
||||
|
||||
element.find("button").simulate("click");
|
||||
expect(element.find("button").text()).toEqual("\u2212");
|
||||
const crl = element.find("ConnectionRowList");
|
||||
expect(crl).toHaveLength(1);
|
||||
expect(crl).not.toHaveLength(0);
|
||||
expect(crl.prop("sharedProps")).toEqual(sharedProps);
|
||||
expect(crl.prop("depth")).toBe(depth + 1);
|
||||
expect(crl.prop("node")).toBe(source);
|
||||
|
||||
element.find("button").simulate("click");
|
||||
expect(element.find("button").text()).toEqual("+");
|
||||
expect(element.find("ConnectionRowList")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConnectionView", () => {
|
||||
async function setup() {
|
||||
const {pnd, adapters, nodes} = await example();
|
||||
const {scoredConnections} = NullUtil.get(pnd.get(nodes.bar1));
|
||||
const connections = scoredConnections.map((sc) => sc.connection);
|
||||
function connectionByType(t) {
|
||||
return NullUtil.get(
|
||||
connections.filter((c) => c.adjacency.type === t)[0],
|
||||
`Couldn't find connection for type ${t}`
|
||||
);
|
||||
}
|
||||
const inConnection = connectionByType("IN_EDGE");
|
||||
const outConnection = connectionByType("OUT_EDGE");
|
||||
const syntheticConnection = connectionByType("SYNTHETIC_LOOP");
|
||||
function cvForConnection(connection: Connection) {
|
||||
return shallow(
|
||||
<ConnectionView adapters={adapters} connection={connection} />
|
||||
);
|
||||
}
|
||||
return {
|
||||
adapters,
|
||||
connections,
|
||||
pnd,
|
||||
cvForConnection,
|
||||
inConnection,
|
||||
outConnection,
|
||||
syntheticConnection,
|
||||
};
|
||||
}
|
||||
it("always renders exactly one `Badge`", async () => {
|
||||
const {
|
||||
cvForConnection,
|
||||
inConnection,
|
||||
outConnection,
|
||||
syntheticConnection,
|
||||
} = await setup();
|
||||
for (const connection of [
|
||||
syntheticConnection,
|
||||
inConnection,
|
||||
outConnection,
|
||||
]) {
|
||||
expect(cvForConnection(connection).find("Badge")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
it("for inward connections, renders a `Badge` and description", async () => {
|
||||
const {cvForConnection, inConnection} = await setup();
|
||||
const view = cvForConnection(inConnection);
|
||||
const outerSpan = view.find("span").first();
|
||||
const badge = outerSpan.find("Badge");
|
||||
const description = outerSpan.children().find("span");
|
||||
expect(badge.children().text()).toEqual("is barred by");
|
||||
expect(description.text()).toEqual('bar: NodeAddress["bar","a","1"]');
|
||||
});
|
||||
it("for outward connections, renders a `Badge` and description", async () => {
|
||||
const {cvForConnection, outConnection} = await setup();
|
||||
const view = cvForConnection(outConnection);
|
||||
const outerSpan = view.find("span").first();
|
||||
const badge = outerSpan.find("Badge");
|
||||
const description = outerSpan.children().find("span");
|
||||
expect(badge.children().text()).toEqual("bars");
|
||||
expect(description.text()).toEqual("xox node!");
|
||||
});
|
||||
it("for synthetic connections, renders only a `Badge`", async () => {
|
||||
const {cvForConnection, syntheticConnection} = await setup();
|
||||
const view = cvForConnection(syntheticConnection);
|
||||
expect(view.find("span")).toHaveLength(0);
|
||||
expect(
|
||||
view
|
||||
.find("Badge")
|
||||
.children()
|
||||
.text()
|
||||
).toEqual("synthetic loop");
|
||||
});
|
||||
});
|
||||
});
|
165
src/app/credExplorer/pagerankTable/Connection.js
Normal file
165
src/app/credExplorer/pagerankTable/Connection.js
Normal file
@ -0,0 +1,165 @@
|
||||
// @flow
|
||||
|
||||
import React from "react";
|
||||
import * as NullUtil from "../../../util/null";
|
||||
|
||||
import type {NodeAddressT} from "../../../core/graph";
|
||||
import type {Connection} from "../../../core/attribution/graphToMarkovChain";
|
||||
import type {ScoredConnection} from "../../../core/attribution/pagerankNodeDecomposition";
|
||||
import type {DynamicPluginAdapter} from "../../pluginAdapter";
|
||||
|
||||
import {
|
||||
edgeVerb,
|
||||
nodeDescription,
|
||||
scoreDisplay,
|
||||
type SharedProps,
|
||||
type RowState,
|
||||
} from "./shared";
|
||||
|
||||
type ConnectionRowListProps = {|
|
||||
+depth: number,
|
||||
+node: NodeAddressT,
|
||||
+sharedProps: SharedProps,
|
||||
|};
|
||||
|
||||
export class ConnectionRowList extends React.PureComponent<
|
||||
ConnectionRowListProps
|
||||
> {
|
||||
render() {
|
||||
const {depth, node, sharedProps} = this.props;
|
||||
const {pnd, maxEntriesPerList} = sharedProps;
|
||||
const {scoredConnections} = NullUtil.get(pnd.get(node));
|
||||
return (
|
||||
<React.Fragment>
|
||||
{scoredConnections
|
||||
.slice(0, maxEntriesPerList)
|
||||
.map((sc) => (
|
||||
<ConnectionRow
|
||||
key={JSON.stringify(sc.connection.adjacency)}
|
||||
depth={depth}
|
||||
target={node}
|
||||
scoredConnection={sc}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectionRowProps = {|
|
||||
+depth: number,
|
||||
+target: NodeAddressT,
|
||||
+scoredConnection: ScoredConnection,
|
||||
+sharedProps: SharedProps,
|
||||
|};
|
||||
|
||||
export class ConnectionRow extends React.PureComponent<
|
||||
ConnectionRowProps,
|
||||
RowState
|
||||
> {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {expanded: false};
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
sharedProps,
|
||||
target,
|
||||
depth,
|
||||
scoredConnection: {connection, source, sourceScore, connectionScore},
|
||||
} = this.props;
|
||||
const {pnd, adapters} = sharedProps;
|
||||
const {expanded} = this.state;
|
||||
const {score: targetScore} = NullUtil.get(pnd.get(target));
|
||||
const connectionProportion = connectionScore / targetScore;
|
||||
const connectionPercent = (connectionProportion * 100).toFixed(2);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<tr
|
||||
key="self"
|
||||
style={{backgroundColor: `rgba(0,143.4375,0,${1 - 0.9 ** depth})`}}
|
||||
>
|
||||
<td style={{display: "flex", alignItems: "flex-start"}}>
|
||||
<button
|
||||
style={{
|
||||
marginRight: 5,
|
||||
marginLeft: 15 * depth,
|
||||
}}
|
||||
onClick={() => {
|
||||
this.setState(({expanded}) => ({
|
||||
expanded: !expanded,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{expanded ? "\u2212" : "+"}
|
||||
</button>
|
||||
<ConnectionView connection={connection} adapters={adapters} />
|
||||
</td>
|
||||
<td style={{textAlign: "right"}}>{connectionPercent}%</td>
|
||||
<td style={{textAlign: "right"}}>{scoreDisplay(sourceScore)}</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<ConnectionRowList
|
||||
key="children"
|
||||
depth={depth + 1}
|
||||
node={source}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConnectionView extends React.PureComponent<{|
|
||||
+connection: Connection,
|
||||
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
|
||||
|}> {
|
||||
render() {
|
||||
const {connection, adapters} = this.props;
|
||||
function Badge({children}) {
|
||||
return (
|
||||
// The outer <span> acts as a strut to ensure that the badge
|
||||
// takes up a full line height, even though its text is smaller.
|
||||
<span>
|
||||
<span
|
||||
style={{
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 700,
|
||||
fontSize: "smaller",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const {adjacency} = connection;
|
||||
switch (adjacency.type) {
|
||||
case "SYNTHETIC_LOOP":
|
||||
return <Badge>synthetic loop</Badge>;
|
||||
case "IN_EDGE":
|
||||
return (
|
||||
<span>
|
||||
<Badge>
|
||||
{edgeVerb(adjacency.edge.address, "BACKWARD", adapters)}
|
||||
</Badge>{" "}
|
||||
<span>{nodeDescription(adjacency.edge.src, adapters)}</span>
|
||||
</span>
|
||||
);
|
||||
case "OUT_EDGE":
|
||||
return (
|
||||
<span>
|
||||
<Badge>
|
||||
{edgeVerb(adjacency.edge.address, "FORWARD", adapters)}
|
||||
</Badge>{" "}
|
||||
<span>{nodeDescription(adjacency.edge.dst, adapters)}</span>
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
throw new Error((adjacency.type: empty));
|
||||
}
|
||||
}
|
||||
}
|
245
src/app/credExplorer/pagerankTable/Connection.test.js
Normal file
245
src/app/credExplorer/pagerankTable/Connection.test.js
Normal file
@ -0,0 +1,245 @@
|
||||
// @flow
|
||||
|
||||
import React from "react";
|
||||
import {shallow} from "enzyme";
|
||||
import * as NullUtil from "../../../util/null";
|
||||
|
||||
import type {Connection} from "../../../core/attribution/graphToMarkovChain";
|
||||
import {ConnectionRowList, ConnectionRow, ConnectionView} from "./Connection";
|
||||
import {example, COLUMNS} from "./sharedTestUtils";
|
||||
|
||||
require("../../testUtil").configureEnzyme();
|
||||
|
||||
describe("app/credExplorer/pagerankTable/Connection", () => {
|
||||
beforeEach(() => {
|
||||
// $ExpectFlowError
|
||||
console.error = jest.fn();
|
||||
// $ExpectFlowError
|
||||
console.warn = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("ConnectionRowList", () => {
|
||||
async function setup(maxEntriesPerList: number = 100000) {
|
||||
const {adapters, pnd, nodes} = await example();
|
||||
const depth = 2;
|
||||
const node = nodes.bar1;
|
||||
const sharedProps = {adapters, pnd, maxEntriesPerList};
|
||||
const component = (
|
||||
<ConnectionRowList
|
||||
depth={depth}
|
||||
node={node}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
);
|
||||
const element = shallow(component);
|
||||
return {element, depth, node, sharedProps};
|
||||
}
|
||||
it("creates `ConnectionRow`s with the right props", async () => {
|
||||
const {element, depth, node, sharedProps} = await setup();
|
||||
const connections = NullUtil.get(sharedProps.pnd.get(node))
|
||||
.scoredConnections;
|
||||
const rows = element.find("ConnectionRow");
|
||||
expect(rows).toHaveLength(connections.length);
|
||||
const rowPropses = rows.map((row) => row.props());
|
||||
// Order should be the same as the order in the decomposition.
|
||||
expect(rowPropses).toEqual(
|
||||
connections.map((sc) => ({
|
||||
depth,
|
||||
sharedProps,
|
||||
target: node,
|
||||
scoredConnection: sc,
|
||||
}))
|
||||
);
|
||||
});
|
||||
it("limits the number of rows by `maxEntriesPerList`", async () => {
|
||||
const maxEntriesPerList = 1;
|
||||
const {element, node, sharedProps} = await setup(maxEntriesPerList);
|
||||
const connections = NullUtil.get(sharedProps.pnd.get(node))
|
||||
.scoredConnections;
|
||||
expect(connections.length).toBeGreaterThan(maxEntriesPerList);
|
||||
const rows = element.find("ConnectionRow");
|
||||
expect(rows).toHaveLength(maxEntriesPerList);
|
||||
const rowConnections = rows.map((row) => row.prop("scoredConnection"));
|
||||
// Should have selected the right nodes.
|
||||
expect(rowConnections).toEqual(connections.slice(0, maxEntriesPerList));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConnectionRow", () => {
|
||||
async function setup() {
|
||||
const {pnd, adapters, nodes} = await example();
|
||||
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
|
||||
const target = nodes.bar1;
|
||||
const {scoredConnections} = NullUtil.get(pnd.get(target));
|
||||
const alphaConnections = scoredConnections.filter(
|
||||
(sc) => sc.source === nodes.fooAlpha
|
||||
);
|
||||
expect(alphaConnections).toHaveLength(1);
|
||||
const connection = alphaConnections[0];
|
||||
const {source} = connection;
|
||||
const depth = 2;
|
||||
const component = (
|
||||
<ConnectionRow
|
||||
depth={depth}
|
||||
target={target}
|
||||
scoredConnection={connection}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
);
|
||||
const element = shallow(component);
|
||||
return {element, depth, target, source, connection, sharedProps};
|
||||
}
|
||||
it("renders the right number of columns", async () => {
|
||||
expect((await setup()).element.find("td")).toHaveLength(COLUMNS().length);
|
||||
});
|
||||
it("has proper depth-based styling", async () => {
|
||||
const {element} = await setup();
|
||||
expect({
|
||||
buttonStyle: element.find("button").prop("style"),
|
||||
trStyle: element.find("tr").prop("style"),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
it("renders the source view", async () => {
|
||||
const {element, sharedProps, connection} = await setup();
|
||||
const descriptionColumn = COLUMNS().indexOf("Description");
|
||||
expect(descriptionColumn).not.toEqual(-1);
|
||||
const view = element
|
||||
.find("td")
|
||||
.at(descriptionColumn)
|
||||
.find("ConnectionView");
|
||||
expect(view).toHaveLength(1);
|
||||
expect(view.props()).toEqual({
|
||||
adapters: sharedProps.adapters,
|
||||
connection: connection.connection,
|
||||
});
|
||||
});
|
||||
it("renders the connection percentage", async () => {
|
||||
const {element, connection, sharedProps, target} = await setup();
|
||||
const connectionColumn = COLUMNS().indexOf("Connection");
|
||||
expect(connectionColumn).not.toEqual(-1);
|
||||
const proportion =
|
||||
connection.connectionScore /
|
||||
NullUtil.get(sharedProps.pnd.get(target)).score;
|
||||
expect(proportion).toBeGreaterThan(0.0);
|
||||
expect(proportion).toBeLessThan(1.0);
|
||||
const expectedText = (proportion * 100).toFixed(2) + "%";
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(connectionColumn)
|
||||
.text()
|
||||
).toEqual(expectedText);
|
||||
});
|
||||
it("renders a score column with the source's score", async () => {
|
||||
const {element, connection} = await setup();
|
||||
const expectedScore = connection.sourceScore.toFixed(2);
|
||||
const connectionColumn = COLUMNS().indexOf("Score");
|
||||
expect(connectionColumn).not.toEqual(-1);
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(connectionColumn)
|
||||
.text()
|
||||
).toEqual(expectedScore);
|
||||
});
|
||||
it("does not render children by default", async () => {
|
||||
const {element} = await setup();
|
||||
expect(element.find("ConnectionRowList")).toHaveLength(0);
|
||||
});
|
||||
it('has a working "expand" button', async () => {
|
||||
const {element, depth, sharedProps, source} = await setup();
|
||||
expect(element.find("button").text()).toEqual("+");
|
||||
|
||||
element.find("button").simulate("click");
|
||||
expect(element.find("button").text()).toEqual("\u2212");
|
||||
const crl = element.find("ConnectionRowList");
|
||||
expect(crl).toHaveLength(1);
|
||||
expect(crl).not.toHaveLength(0);
|
||||
expect(crl.prop("sharedProps")).toEqual(sharedProps);
|
||||
expect(crl.prop("depth")).toBe(depth + 1);
|
||||
expect(crl.prop("node")).toBe(source);
|
||||
|
||||
element.find("button").simulate("click");
|
||||
expect(element.find("button").text()).toEqual("+");
|
||||
expect(element.find("ConnectionRowList")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
describe("ConnectionView", () => {
|
||||
async function setup() {
|
||||
const {pnd, adapters, nodes} = await example();
|
||||
const {scoredConnections} = NullUtil.get(pnd.get(nodes.bar1));
|
||||
const connections = scoredConnections.map((sc) => sc.connection);
|
||||
function connectionByType(t) {
|
||||
return NullUtil.get(
|
||||
connections.filter((c) => c.adjacency.type === t)[0],
|
||||
`Couldn't find connection for type ${t}`
|
||||
);
|
||||
}
|
||||
const inConnection = connectionByType("IN_EDGE");
|
||||
const outConnection = connectionByType("OUT_EDGE");
|
||||
const syntheticConnection = connectionByType("SYNTHETIC_LOOP");
|
||||
function cvForConnection(connection: Connection) {
|
||||
return shallow(
|
||||
<ConnectionView adapters={adapters} connection={connection} />
|
||||
);
|
||||
}
|
||||
return {
|
||||
adapters,
|
||||
connections,
|
||||
pnd,
|
||||
cvForConnection,
|
||||
inConnection,
|
||||
outConnection,
|
||||
syntheticConnection,
|
||||
};
|
||||
}
|
||||
it("always renders exactly one `Badge`", async () => {
|
||||
const {
|
||||
cvForConnection,
|
||||
inConnection,
|
||||
outConnection,
|
||||
syntheticConnection,
|
||||
} = await setup();
|
||||
for (const connection of [
|
||||
syntheticConnection,
|
||||
inConnection,
|
||||
outConnection,
|
||||
]) {
|
||||
expect(cvForConnection(connection).find("Badge")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
it("for inward connections, renders a `Badge` and description", async () => {
|
||||
const {cvForConnection, inConnection} = await setup();
|
||||
const view = cvForConnection(inConnection);
|
||||
const outerSpan = view.find("span").first();
|
||||
const badge = outerSpan.find("Badge");
|
||||
const description = outerSpan.children().find("span");
|
||||
expect(badge.children().text()).toEqual("is barred by");
|
||||
expect(description.text()).toEqual('bar: NodeAddress["bar","a","1"]');
|
||||
});
|
||||
it("for outward connections, renders a `Badge` and description", async () => {
|
||||
const {cvForConnection, outConnection} = await setup();
|
||||
const view = cvForConnection(outConnection);
|
||||
const outerSpan = view.find("span").first();
|
||||
const badge = outerSpan.find("Badge");
|
||||
const description = outerSpan.children().find("span");
|
||||
expect(badge.children().text()).toEqual("bars");
|
||||
expect(description.text()).toEqual("xox node!");
|
||||
});
|
||||
it("for synthetic connections, renders only a `Badge`", async () => {
|
||||
const {cvForConnection, syntheticConnection} = await setup();
|
||||
const view = cvForConnection(syntheticConnection);
|
||||
expect(view.find("span")).toHaveLength(0);
|
||||
expect(
|
||||
view
|
||||
.find("Badge")
|
||||
.children()
|
||||
.text()
|
||||
).toEqual("synthetic loop");
|
||||
});
|
||||
});
|
||||
});
|
84
src/app/credExplorer/pagerankTable/Node.js
Normal file
84
src/app/credExplorer/pagerankTable/Node.js
Normal file
@ -0,0 +1,84 @@
|
||||
// @flow
|
||||
|
||||
import React from "react";
|
||||
import sortBy from "lodash.sortby";
|
||||
import * as NullUtil from "../../../util/null";
|
||||
|
||||
import {type NodeAddressT} from "../../../core/graph";
|
||||
|
||||
import {
|
||||
nodeDescription,
|
||||
scoreDisplay,
|
||||
type SharedProps,
|
||||
type RowState,
|
||||
} from "./shared";
|
||||
|
||||
import {ConnectionRowList} from "./Connection";
|
||||
|
||||
type NodeRowListProps = {|
|
||||
+nodes: $ReadOnlyArray<NodeAddressT>,
|
||||
+sharedProps: SharedProps,
|
||||
|};
|
||||
|
||||
export class NodeRowList extends React.PureComponent<NodeRowListProps> {
|
||||
render() {
|
||||
const {nodes, sharedProps} = this.props;
|
||||
const {pnd, maxEntriesPerList} = sharedProps;
|
||||
return (
|
||||
<React.Fragment>
|
||||
{sortBy(nodes, (n) => -NullUtil.get(pnd.get(n)).score, (n) => n)
|
||||
.slice(0, maxEntriesPerList)
|
||||
.map((node) => (
|
||||
<NodeRow node={node} key={node} sharedProps={sharedProps} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type NodeRowProps = {|
|
||||
+node: NodeAddressT,
|
||||
+sharedProps: SharedProps,
|
||||
|};
|
||||
|
||||
export class NodeRow extends React.PureComponent<NodeRowProps, RowState> {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {expanded: false};
|
||||
}
|
||||
render() {
|
||||
const {node, sharedProps} = this.props;
|
||||
const {pnd, adapters} = sharedProps;
|
||||
const {expanded} = this.state;
|
||||
const {score} = NullUtil.get(pnd.get(node));
|
||||
return (
|
||||
<React.Fragment>
|
||||
<tr key="self">
|
||||
<td style={{display: "flex", alignItems: "flex-start"}}>
|
||||
<button
|
||||
style={{marginRight: 5}}
|
||||
onClick={() => {
|
||||
this.setState(({expanded}) => ({
|
||||
expanded: !expanded,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{expanded ? "\u2212" : "+"}
|
||||
</button>
|
||||
<span>{nodeDescription(node, adapters)}</span>
|
||||
</td>
|
||||
<td style={{textAlign: "right"}}>{"—"}</td>
|
||||
<td style={{textAlign: "right"}}>{scoreDisplay(score)}</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<ConnectionRowList
|
||||
key="children"
|
||||
depth={1}
|
||||
node={node}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
152
src/app/credExplorer/pagerankTable/Node.test.js
Normal file
152
src/app/credExplorer/pagerankTable/Node.test.js
Normal file
@ -0,0 +1,152 @@
|
||||
// @flow
|
||||
|
||||
import React from "react";
|
||||
import {shallow} from "enzyme";
|
||||
import sortBy from "lodash.sortby";
|
||||
import * as NullUtil from "../../../util/null";
|
||||
|
||||
import {type NodeAddressT, NodeAddress} from "../../../core/graph";
|
||||
|
||||
import {example, COLUMNS} from "./sharedTestUtils";
|
||||
import {NodeRowList, NodeRow} from "./Node";
|
||||
|
||||
require("../../testUtil").configureEnzyme();
|
||||
|
||||
describe("app/credExplorer/pagerankTable/Node", () => {
|
||||
beforeEach(() => {
|
||||
// $ExpectFlowError
|
||||
console.error = jest.fn();
|
||||
// $ExpectFlowError
|
||||
console.warn = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
describe("NodeRowList", () => {
|
||||
function sortedByScore(nodes: $ReadOnlyArray<NodeAddressT>, pnd) {
|
||||
return sortBy(nodes, (node) => -NullUtil.get(pnd.get(node)).score);
|
||||
}
|
||||
async function setup(maxEntriesPerList: number = 100000) {
|
||||
const {adapters, pnd} = await example();
|
||||
const nodes = sortedByScore(Array.from(pnd.keys()), pnd)
|
||||
.reverse() // ascending order!
|
||||
.filter((x) =>
|
||||
NodeAddress.hasPrefix(x, NodeAddress.fromParts(["foo"]))
|
||||
);
|
||||
expect(nodes).not.toHaveLength(0);
|
||||
expect(nodes).not.toHaveLength(1);
|
||||
expect(nodes).not.toHaveLength(pnd.size);
|
||||
const sharedProps = {adapters, pnd, maxEntriesPerList};
|
||||
const component = <NodeRowList sharedProps={sharedProps} nodes={nodes} />;
|
||||
const element = shallow(component);
|
||||
return {element, adapters, sharedProps, nodes};
|
||||
}
|
||||
it("creates `NodeRow`s with the right props", async () => {
|
||||
const {element, nodes, sharedProps} = await setup();
|
||||
const rows = element.find("NodeRow");
|
||||
expect(rows).toHaveLength(nodes.length);
|
||||
const rowNodes = rows.map((row) => row.prop("node"));
|
||||
// Check that we selected the right set of nodes. We'll check
|
||||
// order in a separate test case.
|
||||
expect(rowNodes.slice().sort()).toEqual(nodes.slice().sort());
|
||||
rows.forEach((row) => {
|
||||
expect(row.prop("sharedProps")).toEqual(sharedProps);
|
||||
});
|
||||
});
|
||||
it("creates up to `maxEntriesPerList` `NodeRow`s", async () => {
|
||||
const maxEntriesPerList = 1;
|
||||
const {element, nodes, sharedProps} = await setup(maxEntriesPerList);
|
||||
expect(nodes.length).toBeGreaterThan(maxEntriesPerList);
|
||||
const rows = element.find("NodeRow");
|
||||
expect(rows).toHaveLength(maxEntriesPerList);
|
||||
const rowNodes = rows.map((row) => row.prop("node"));
|
||||
// Should have selected the right nodes.
|
||||
expect(rowNodes).toEqual(
|
||||
sortedByScore(nodes, sharedProps.pnd).slice(0, maxEntriesPerList)
|
||||
);
|
||||
});
|
||||
it("sorts its children by score", async () => {
|
||||
const {
|
||||
element,
|
||||
nodes,
|
||||
sharedProps: {pnd},
|
||||
} = await setup();
|
||||
expect(nodes).not.toEqual(sortedByScore(nodes, pnd));
|
||||
const rows = element.find("NodeRow");
|
||||
const rowNodes = rows.map((row) => row.prop("node"));
|
||||
expect(rowNodes).toEqual(sortedByScore(rowNodes, pnd));
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeRow", () => {
|
||||
async function setup() {
|
||||
const {pnd, adapters, nodes} = await example();
|
||||
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
|
||||
const node = nodes.bar1;
|
||||
const component = <NodeRow node={node} sharedProps={sharedProps} />;
|
||||
const element = shallow(component);
|
||||
return {element, node, sharedProps};
|
||||
}
|
||||
it("renders the right number of columns", async () => {
|
||||
expect((await setup()).element.find("td")).toHaveLength(COLUMNS().length);
|
||||
});
|
||||
it("renders the node description", async () => {
|
||||
const {element} = await setup();
|
||||
const expectedDescription = 'bar: NodeAddress["bar","a","1"]';
|
||||
const descriptionColumn = COLUMNS().indexOf("Description");
|
||||
expect(descriptionColumn).not.toEqual(-1);
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(descriptionColumn)
|
||||
.find("span")
|
||||
.text()
|
||||
).toEqual(expectedDescription);
|
||||
});
|
||||
it("renders an empty connection column", async () => {
|
||||
const {element} = await setup();
|
||||
const connectionColumn = COLUMNS().indexOf("Connection");
|
||||
expect(connectionColumn).not.toEqual(-1);
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(connectionColumn)
|
||||
.text()
|
||||
).toEqual("—");
|
||||
});
|
||||
it("renders a score column with the node's score", async () => {
|
||||
const {element, sharedProps, node} = await setup();
|
||||
const {score} = NullUtil.get(sharedProps.pnd.get(node));
|
||||
const expectedScore = score.toFixed(2);
|
||||
const connectionColumn = COLUMNS().indexOf("Score");
|
||||
expect(connectionColumn).not.toEqual(-1);
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(connectionColumn)
|
||||
.text()
|
||||
).toEqual(expectedScore);
|
||||
});
|
||||
it("does not render children by default", async () => {
|
||||
const {element} = await setup();
|
||||
expect(element.find("ConnectionRowList")).toHaveLength(0);
|
||||
});
|
||||
it('has a working "expand" button', async () => {
|
||||
const {element, sharedProps, node} = await setup();
|
||||
expect(element.find("button").text()).toEqual("+");
|
||||
|
||||
element.find("button").simulate("click");
|
||||
expect(element.find("button").text()).toEqual("\u2212");
|
||||
const crl = element.find("ConnectionRowList");
|
||||
expect(crl).toHaveLength(1);
|
||||
expect(crl.prop("sharedProps")).toEqual(sharedProps);
|
||||
expect(crl.prop("depth")).toBe(1);
|
||||
expect(crl.prop("node")).toBe(node);
|
||||
|
||||
element.find("button").simulate("click");
|
||||
expect(element.find("button").text()).toEqual("+");
|
||||
expect(element.find("ConnectionRowList")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
115
src/app/credExplorer/pagerankTable/Table.js
Normal file
115
src/app/credExplorer/pagerankTable/Table.js
Normal file
@ -0,0 +1,115 @@
|
||||
// @flow
|
||||
|
||||
import React from "react";
|
||||
import sortBy from "lodash.sortby";
|
||||
|
||||
import {type NodeAddressT, NodeAddress} from "../../../core/graph";
|
||||
import type {PagerankNodeDecomposition} from "../../../core/attribution/pagerankNodeDecomposition";
|
||||
import type {DynamicPluginAdapter} from "../../pluginAdapter";
|
||||
|
||||
import {NodeRowList} from "./Node";
|
||||
|
||||
type PagerankTableProps = {|
|
||||
+pnd: PagerankNodeDecomposition,
|
||||
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
|
||||
+maxEntriesPerList: number,
|
||||
|};
|
||||
type PagerankTableState = {|topLevelFilter: NodeAddressT|};
|
||||
export class PagerankTable extends React.PureComponent<
|
||||
PagerankTableProps,
|
||||
PagerankTableState
|
||||
> {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {topLevelFilter: NodeAddress.empty};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div style={{marginTop: 10}}>
|
||||
{this.renderFilterSelect()}
|
||||
{this.renderTable()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderFilterSelect() {
|
||||
const {pnd, adapters} = this.props;
|
||||
if (pnd == null || adapters == null) {
|
||||
throw new Error("Impossible.");
|
||||
}
|
||||
|
||||
function optionGroup(adapter: DynamicPluginAdapter) {
|
||||
const header = (
|
||||
<option
|
||||
key={adapter.static().nodePrefix()}
|
||||
value={adapter.static().nodePrefix()}
|
||||
style={{fontWeight: "bold"}}
|
||||
>
|
||||
{adapter.static().name()}
|
||||
</option>
|
||||
);
|
||||
const entries = adapter
|
||||
.static()
|
||||
.nodeTypes()
|
||||
.map((type) => (
|
||||
<option key={type.prefix} value={type.prefix}>
|
||||
{"\u2003" + type.name}
|
||||
</option>
|
||||
));
|
||||
return [header, ...entries];
|
||||
}
|
||||
return (
|
||||
<label>
|
||||
<span>Filter by node type: </span>
|
||||
<select
|
||||
value={this.state.topLevelFilter}
|
||||
onChange={(e) => {
|
||||
this.setState({topLevelFilter: e.target.value});
|
||||
}}
|
||||
>
|
||||
<option value={NodeAddress.empty}>Show all</option>
|
||||
{sortBy(adapters, (a: DynamicPluginAdapter) => a.static().name()).map(
|
||||
optionGroup
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
renderTable() {
|
||||
const {pnd, adapters, maxEntriesPerList} = this.props;
|
||||
if (pnd == null || adapters == null || maxEntriesPerList == null) {
|
||||
throw new Error("Impossible.");
|
||||
}
|
||||
const topLevelFilter = this.state.topLevelFilter;
|
||||
const sharedProps = {pnd, adapters, maxEntriesPerList};
|
||||
return (
|
||||
<table
|
||||
style={{
|
||||
borderCollapse: "collapse",
|
||||
marginTop: 10,
|
||||
// If we don't subtract 1px here, then a horizontal scrollbar
|
||||
// appears in Chrome (but not Firefox). I'm not sure why.
|
||||
width: "calc(100% - 1px)",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{textAlign: "left"}}>Description</th>
|
||||
<th style={{textAlign: "right"}}>Connection</th>
|
||||
<th style={{textAlign: "right"}}>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<NodeRowList
|
||||
sharedProps={sharedProps}
|
||||
nodes={Array.from(pnd.keys()).filter((node) =>
|
||||
NodeAddress.hasPrefix(node, topLevelFilter)
|
||||
)}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
105
src/app/credExplorer/pagerankTable/Table.test.js
Normal file
105
src/app/credExplorer/pagerankTable/Table.test.js
Normal file
@ -0,0 +1,105 @@
|
||||
// @flow
|
||||
|
||||
import React from "react";
|
||||
import {shallow} from "enzyme";
|
||||
|
||||
import {NodeAddress} from "../../../core/graph";
|
||||
|
||||
import {PagerankTable} from "./Table";
|
||||
import {example, COLUMNS} from "./sharedTestUtils";
|
||||
|
||||
require("../../testUtil").configureEnzyme();
|
||||
describe("app/credExplorer/pagerankTable/Table", () => {
|
||||
beforeEach(() => {
|
||||
// $ExpectFlowError
|
||||
console.error = jest.fn();
|
||||
// $ExpectFlowError
|
||||
console.warn = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
describe("PagerankTable", () => {
|
||||
it("renders thead column order properly", async () => {
|
||||
const {pnd, adapters} = await example();
|
||||
const element = shallow(
|
||||
<PagerankTable pnd={pnd} adapters={adapters} maxEntriesPerList={1} />
|
||||
);
|
||||
const th = element.find("thead th");
|
||||
const columnNames = th.map((t) => t.text());
|
||||
expect(columnNames).toEqual(COLUMNS());
|
||||
});
|
||||
|
||||
describe("has a filter select", () => {
|
||||
async function setup() {
|
||||
const {pnd, adapters} = await example();
|
||||
const element = shallow(
|
||||
<PagerankTable pnd={pnd} adapters={adapters} maxEntriesPerList={1} />
|
||||
);
|
||||
const label = element.find("label");
|
||||
const options = label.find("option");
|
||||
return {pnd, adapters, element, label, options};
|
||||
}
|
||||
it("with expected label text", async () => {
|
||||
const {label} = await setup();
|
||||
const filterText = label
|
||||
.find("span")
|
||||
.first()
|
||||
.text();
|
||||
expect(filterText).toMatchSnapshot();
|
||||
});
|
||||
it("with expected option groups", async () => {
|
||||
const {options} = await setup();
|
||||
const optionsJSON = options.map((o) => ({
|
||||
valueString: NodeAddress.toString(o.prop("value")),
|
||||
style: o.prop("style"),
|
||||
text: o.text(),
|
||||
}));
|
||||
expect(optionsJSON).toMatchSnapshot();
|
||||
});
|
||||
it("with the ability to filter nodes passed to NodeRowList", async () => {
|
||||
const {element, options} = await setup();
|
||||
const option1 = options.at(1);
|
||||
const value = option1.prop("value");
|
||||
expect(value).not.toEqual(NodeAddress.empty);
|
||||
const previousNodes = element.find("NodeRowList").prop("nodes");
|
||||
expect(
|
||||
previousNodes.every((n) => NodeAddress.hasPrefix(n, value))
|
||||
).toBe(false);
|
||||
element.find("select").simulate("change", {target: {value}});
|
||||
const actualNodes = element.find("NodeRowList").prop("nodes");
|
||||
expect(actualNodes.every((n) => NodeAddress.hasPrefix(n, value))).toBe(
|
||||
true
|
||||
);
|
||||
expect(actualNodes).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("creates a NodeRowList", () => {
|
||||
async function setup() {
|
||||
const {adapters, pnd} = await example();
|
||||
const maxEntriesPerList = 1;
|
||||
const element = shallow(
|
||||
<PagerankTable
|
||||
pnd={pnd}
|
||||
adapters={adapters}
|
||||
maxEntriesPerList={maxEntriesPerList}
|
||||
/>
|
||||
);
|
||||
const nrl = element.find("NodeRowList");
|
||||
return {adapters, pnd, element, nrl, maxEntriesPerList};
|
||||
}
|
||||
it("with the correct SharedProps", async () => {
|
||||
const {nrl, adapters, pnd, maxEntriesPerList} = await setup();
|
||||
const expectedSharedProps = {adapters, pnd, maxEntriesPerList};
|
||||
expect(nrl.prop("sharedProps")).toEqual(expectedSharedProps);
|
||||
});
|
||||
it("including all nodes by default", async () => {
|
||||
const {nrl, pnd} = await setup();
|
||||
const expectedNodes = Array.from(pnd.keys());
|
||||
expect(nrl.prop("nodes")).toEqual(expectedNodes);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,13 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`app/credExplorer/pagerankTable/Connection ConnectionRow has proper depth-based styling 1`] = `
|
||||
Object {
|
||||
"buttonStyle": Object {
|
||||
"marginLeft": 30,
|
||||
"marginRight": 5,
|
||||
},
|
||||
"trStyle": Object {
|
||||
"backgroundColor": "rgba(0,143.4375,0,0.18999999999999995)",
|
||||
},
|
||||
}
|
||||
`;
|
@ -1,20 +1,8 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`app/credExplorer/PagerankTable ConnectionRow has proper depth-based styling 1`] = `
|
||||
Object {
|
||||
"buttonStyle": Object {
|
||||
"marginLeft": 30,
|
||||
"marginRight": 5,
|
||||
},
|
||||
"trStyle": Object {
|
||||
"backgroundColor": "rgba(0,143.4375,0,0.18999999999999995)",
|
||||
},
|
||||
}
|
||||
`;
|
||||
exports[`app/credExplorer/pagerankTable/Table PagerankTable has a filter select with expected label text 1`] = `"Filter by node type: "`;
|
||||
|
||||
exports[`app/credExplorer/PagerankTable PagerankTable has a filter select with expected label text 1`] = `"Filter by node type: "`;
|
||||
|
||||
exports[`app/credExplorer/PagerankTable PagerankTable has a filter select with expected option groups 1`] = `
|
||||
exports[`app/credExplorer/pagerankTable/Table PagerankTable has a filter select with expected option groups 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"style": undefined,
|
54
src/app/credExplorer/pagerankTable/shared.js
Normal file
54
src/app/credExplorer/pagerankTable/shared.js
Normal file
@ -0,0 +1,54 @@
|
||||
// @flow
|
||||
|
||||
import {
|
||||
type EdgeAddressT,
|
||||
type NodeAddressT,
|
||||
NodeAddress,
|
||||
} from "../../../core/graph";
|
||||
|
||||
import type {PagerankNodeDecomposition} from "../../../core/attribution/pagerankNodeDecomposition";
|
||||
|
||||
import {
|
||||
type DynamicPluginAdapter,
|
||||
dynamicDispatchByNode,
|
||||
dynamicDispatchByEdge,
|
||||
findEdgeType,
|
||||
} from "../../pluginAdapter";
|
||||
|
||||
export function nodeDescription(
|
||||
address: NodeAddressT,
|
||||
adapters: $ReadOnlyArray<DynamicPluginAdapter>
|
||||
): string {
|
||||
const adapter = dynamicDispatchByNode(adapters, address);
|
||||
try {
|
||||
return adapter.nodeDescription(address);
|
||||
} catch (e) {
|
||||
const result = NodeAddress.toString(address);
|
||||
console.error(`Error getting description for ${result}: ${e.message}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export function edgeVerb(
|
||||
address: EdgeAddressT,
|
||||
direction: "FORWARD" | "BACKWARD",
|
||||
adapters: $ReadOnlyArray<DynamicPluginAdapter>
|
||||
): string {
|
||||
const adapter = dynamicDispatchByEdge(adapters, address);
|
||||
const edgeType = findEdgeType(adapter.static(), address);
|
||||
return direction === "FORWARD" ? edgeType.forwardName : edgeType.backwardName;
|
||||
}
|
||||
|
||||
export function scoreDisplay(score: number) {
|
||||
return score.toFixed(2);
|
||||
}
|
||||
|
||||
export type SharedProps = {|
|
||||
+pnd: PagerankNodeDecomposition,
|
||||
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
|
||||
+maxEntriesPerList: number,
|
||||
|};
|
||||
|
||||
export type RowState = {|
|
||||
expanded: boolean,
|
||||
|};
|
141
src/app/credExplorer/pagerankTable/sharedTestUtils.js
Normal file
141
src/app/credExplorer/pagerankTable/sharedTestUtils.js
Normal file
@ -0,0 +1,141 @@
|
||||
// @flow
|
||||
|
||||
import {Graph, NodeAddress, EdgeAddress} from "../../../core/graph";
|
||||
|
||||
import type {DynamicPluginAdapter} from "../../pluginAdapter";
|
||||
import {pagerank} from "../../../core/attribution/pagerank";
|
||||
|
||||
export const COLUMNS = () => ["Description", "Connection", "Score"];
|
||||
|
||||
export async 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);
|
||||
return edge;
|
||||
}
|
||||
|
||||
const edges = {
|
||||
fooA: addEdge(["foo", "a"], nodes.fooAlpha, nodes.fooBeta),
|
||||
fooB: addEdge(["foo", "b"], nodes.fooAlpha, nodes.bar1),
|
||||
fooC: addEdge(["foo", "c"], nodes.fooAlpha, nodes.xox),
|
||||
barD: addEdge(["bar", "d"], nodes.bar1, nodes.bar1),
|
||||
barE: addEdge(["bar", "e"], nodes.bar1, nodes.xox),
|
||||
barF: addEdge(["bar", "f"], nodes.bar1, nodes.xox),
|
||||
};
|
||||
|
||||
const adapters: DynamicPluginAdapter[] = [
|
||||
{
|
||||
static: () => ({
|
||||
name: () => "foo",
|
||||
nodePrefix: () => NodeAddress.fromParts(["foo"]),
|
||||
edgePrefix: () => EdgeAddress.fromParts(["foo"]),
|
||||
nodeTypes: () => [
|
||||
{
|
||||
name: "alpha",
|
||||
prefix: NodeAddress.fromParts(["foo", "a"]),
|
||||
defaultWeight: 1,
|
||||
},
|
||||
{
|
||||
name: "beta",
|
||||
prefix: NodeAddress.fromParts(["foo", "b"]),
|
||||
defaultWeight: 1,
|
||||
},
|
||||
],
|
||||
edgeTypes: () => [
|
||||
{
|
||||
prefix: EdgeAddress.fromParts(["foo"]),
|
||||
forwardName: "foos",
|
||||
backwardName: "is fooed by",
|
||||
},
|
||||
],
|
||||
load: (_unused_repo) => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
}),
|
||||
graph: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
nodeDescription: (x) => `foo: ${NodeAddress.toString(x)}`,
|
||||
},
|
||||
{
|
||||
static: () => ({
|
||||
name: () => "bar",
|
||||
nodePrefix: () => NodeAddress.fromParts(["bar"]),
|
||||
edgePrefix: () => EdgeAddress.fromParts(["bar"]),
|
||||
nodeTypes: () => [
|
||||
{
|
||||
name: "alpha",
|
||||
prefix: NodeAddress.fromParts(["bar", "a"]),
|
||||
defaultWeight: 1,
|
||||
},
|
||||
],
|
||||
edgeTypes: () => [
|
||||
{
|
||||
prefix: EdgeAddress.fromParts(["bar"]),
|
||||
forwardName: "bars",
|
||||
backwardName: "is barred by",
|
||||
},
|
||||
],
|
||||
load: (_unused_repo) => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
}),
|
||||
graph: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
nodeDescription: (x) => `bar: ${NodeAddress.toString(x)}`,
|
||||
},
|
||||
{
|
||||
static: () => ({
|
||||
name: () => "xox",
|
||||
nodePrefix: () => NodeAddress.fromParts(["xox"]),
|
||||
edgePrefix: () => EdgeAddress.fromParts(["xox"]),
|
||||
nodeTypes: () => [],
|
||||
edgeTypes: () => [],
|
||||
load: (_unused_repo) => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
}),
|
||||
graph: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
nodeDescription: (_unused_arg) => `xox node!`,
|
||||
},
|
||||
{
|
||||
static: () => ({
|
||||
nodePrefix: () => NodeAddress.fromParts(["unused"]),
|
||||
edgePrefix: () => EdgeAddress.fromParts(["unused"]),
|
||||
nodeTypes: () => [],
|
||||
edgeTypes: () => [],
|
||||
name: () => "unused",
|
||||
load: (_unused_repo) => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
}),
|
||||
graph: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
nodeDescription: () => {
|
||||
throw new Error("Unused");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const pnd = await pagerank(graph, (_unused_Edge) => ({
|
||||
toWeight: 1,
|
||||
froWeight: 1,
|
||||
}));
|
||||
|
||||
return {adapters, nodes, edges, graph, pnd};
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user