Show PageRank node decomposition in the explorer (#507)
Summary: This commit hooks up the PageRank table to the PageRank node decomposition developed previously. The new cred explorer displays one entry per contribution to a node’s cred (i.e., one entry per in-edge, per out-edge, and per synthetic loop), listing the proportion of the node’s cred that is provided by this contribution. This makes it easy to observe facts like, “90% of this issue’s cred is due to being written by a particular author”. Paired with @decentralion. Test Plan: Unit tests added; run `yarn travis`. wchargin-branch: pagerank-table-node-decomposition
This commit is contained in:
parent
bb7b538f44
commit
ab0fa81a40
|
@ -7,11 +7,12 @@ import LocalStore from "./LocalStore";
|
|||
import {createPluginAdapter as createGithubAdapter} from "../../plugins/github/pluginAdapter";
|
||||
import {createPluginAdapter as createGitAdapter} from "../../plugins/git/pluginAdapter";
|
||||
import {Graph} from "../../core/graph";
|
||||
import {pagerank, type NodeDistribution} from "../../core/attribution/pagerank";
|
||||
import {pagerank} from "../../core/attribution/pagerank";
|
||||
import {PagerankTable} from "./PagerankTable";
|
||||
import type {PluginAdapter} from "../pluginAdapter";
|
||||
import {type EdgeEvaluator} from "../../core/attribution/pagerank";
|
||||
import {WeightConfig} from "./WeightConfig";
|
||||
import type {PagerankNodeDecomposition} from "../../core/attribution/pagerankNodeDecomposition";
|
||||
|
||||
import * as NullUtil from "../../util/null";
|
||||
|
||||
|
@ -26,13 +27,14 @@ type State = {
|
|||
+nodeCount: number,
|
||||
+edgeCount: number,
|
||||
|},
|
||||
+pagerankResult: ?NodeDistribution,
|
||||
+pnd: ?PagerankNodeDecomposition,
|
||||
|},
|
||||
edgeEvaluator: ?EdgeEvaluator,
|
||||
};
|
||||
|
||||
const REPO_OWNER_KEY = "repoOwner";
|
||||
const REPO_NAME_KEY = "repoName";
|
||||
const MAX_ENTRIES_PER_LIST = 100;
|
||||
|
||||
export default class App extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
|
@ -40,7 +42,7 @@ export default class App extends React.Component<Props, State> {
|
|||
this.state = {
|
||||
repoOwner: "",
|
||||
repoName: "",
|
||||
data: {graphWithMetadata: null, pagerankResult: null},
|
||||
data: {graphWithMetadata: null, pnd: null},
|
||||
edgeEvaluator: null,
|
||||
};
|
||||
}
|
||||
|
@ -54,7 +56,7 @@ export default class App extends React.Component<Props, State> {
|
|||
|
||||
render() {
|
||||
const {edgeEvaluator} = this.state;
|
||||
const {graphWithMetadata, pagerankResult} = this.state.data;
|
||||
const {graphWithMetadata, pnd} = this.state.data;
|
||||
return (
|
||||
<div style={{maxWidth: "66em", margin: "0 auto", padding: "0 10px"}}>
|
||||
<header className={css(styles.header)}>
|
||||
|
@ -96,10 +98,10 @@ export default class App extends React.Component<Props, State> {
|
|||
throw new Error("Unexpected null value");
|
||||
}
|
||||
const {graph} = graphWithMetadata;
|
||||
const pagerankResult = pagerank(graph, edgeEvaluator, {
|
||||
const pnd = pagerank(graph, edgeEvaluator, {
|
||||
verbose: true,
|
||||
});
|
||||
const data = {graphWithMetadata, pagerankResult};
|
||||
const data = {graphWithMetadata, pnd};
|
||||
// In case a new graph was loaded while waiting for
|
||||
// PageRank.
|
||||
const stomped =
|
||||
|
@ -123,9 +125,9 @@ export default class App extends React.Component<Props, State> {
|
|||
)}
|
||||
<WeightConfig onChange={(ee) => this.setState({edgeEvaluator: ee})} />
|
||||
<PagerankTable
|
||||
graph={NullUtil.map(graphWithMetadata, (x) => x.graph)}
|
||||
adapters={NullUtil.map(graphWithMetadata, (x) => x.adapters)}
|
||||
pagerankResult={pagerankResult}
|
||||
pnd={pnd}
|
||||
maxEntriesPerList={MAX_ENTRIES_PER_LIST}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -166,7 +168,7 @@ export default class App extends React.Component<Props, State> {
|
|||
nodeCount: Array.from(graph.nodes()).length,
|
||||
edgeCount: Array.from(graph.edges()).length,
|
||||
},
|
||||
pagerankResult: null,
|
||||
pnd: null,
|
||||
};
|
||||
this.setState({data});
|
||||
});
|
||||
|
|
|
@ -4,29 +4,18 @@ import sortBy from "lodash.sortby";
|
|||
import React from "react";
|
||||
|
||||
import {
|
||||
Graph,
|
||||
NodeAddress,
|
||||
type NodeAddressT,
|
||||
type Neighbor,
|
||||
Direction,
|
||||
type Edge,
|
||||
EdgeAddress,
|
||||
type EdgeAddressT,
|
||||
type NodeAddressT,
|
||||
EdgeAddress,
|
||||
NodeAddress,
|
||||
} from "../../core/graph";
|
||||
import type {NodeDistribution} from "../../core/attribution/pagerank";
|
||||
import type {
|
||||
PagerankNodeDecomposition,
|
||||
ScoredContribution,
|
||||
} from "../../core/attribution/pagerankNodeDecomposition";
|
||||
import type {Contribution} from "../../core/attribution/graphToMarkovChain";
|
||||
import type {PluginAdapter} from "../pluginAdapter";
|
||||
|
||||
const MAX_TABLE_ENTRIES = 100;
|
||||
|
||||
type Props = {
|
||||
pagerankResult: ?NodeDistribution,
|
||||
graph: ?Graph,
|
||||
adapters: ?$ReadOnlyArray<PluginAdapter>,
|
||||
};
|
||||
|
||||
type State = {
|
||||
topLevelFilter: NodeAddressT,
|
||||
};
|
||||
import * as NullUtil from "../../util/null";
|
||||
|
||||
// TODO: Factor this out and test it (#465)
|
||||
export function nodeDescription(
|
||||
|
@ -74,37 +63,45 @@ function edgeVerb(
|
|||
}
|
||||
}
|
||||
|
||||
export function neighborVerb(
|
||||
{node, edge}: Neighbor,
|
||||
adapters: $ReadOnlyArray<PluginAdapter>
|
||||
): string {
|
||||
const forwardVerb = edgeVerb(edge.address, "FORWARD", adapters);
|
||||
const backwardVerb = edgeVerb(edge.address, "BACKWARD", adapters);
|
||||
if (edge.src === edge.dst) {
|
||||
return `${forwardVerb} and ${backwardVerb}`;
|
||||
} else if (edge.dst === node) {
|
||||
return forwardVerb;
|
||||
} else {
|
||||
return backwardVerb;
|
||||
}
|
||||
function scoreDisplay(probability: number) {
|
||||
const modifiedLogScore = Math.log(probability) + 10;
|
||||
return modifiedLogScore.toFixed(2);
|
||||
}
|
||||
|
||||
export class PagerankTable extends React.PureComponent<Props, State> {
|
||||
type SharedProps = {|
|
||||
+pnd: PagerankNodeDecomposition,
|
||||
+adapters: $ReadOnlyArray<PluginAdapter>,
|
||||
+maxEntriesPerList: number,
|
||||
|};
|
||||
|
||||
type PagerankTableProps = {|
|
||||
+pnd: ?PagerankNodeDecomposition,
|
||||
+adapters: ?$ReadOnlyArray<PluginAdapter>,
|
||||
+maxEntriesPerList: number,
|
||||
|};
|
||||
type PagerankTableState = {|topLevelFilter: NodeAddressT|};
|
||||
export class PagerankTable extends React.PureComponent<
|
||||
PagerankTableProps,
|
||||
PagerankTableState
|
||||
> {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {topLevelFilter: NodeAddress.empty};
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.graph == null || this.props.adapters == null) {
|
||||
if (this.props.adapters == null) {
|
||||
return <p>You must load a graph before seeing PageRank analysis.</p>;
|
||||
}
|
||||
if (this.props.pagerankResult == null) {
|
||||
if (this.props.pnd == null) {
|
||||
return <p>Please run PageRank to see analysis.</p>;
|
||||
}
|
||||
if (this.props.maxEntriesPerList == null) {
|
||||
throw new Error("maxEntriesPerList not set");
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h2>Contributions</h2>
|
||||
<h2>PageRank results</h2>
|
||||
{this.renderFilterSelect()}
|
||||
{this.renderTable()}
|
||||
</div>
|
||||
|
@ -112,8 +109,8 @@ export class PagerankTable extends React.PureComponent<Props, State> {
|
|||
}
|
||||
|
||||
renderFilterSelect() {
|
||||
const {graph, pagerankResult, adapters} = this.props;
|
||||
if (graph == null || pagerankResult == null || adapters == null) {
|
||||
const {pnd, adapters} = this.props;
|
||||
if (pnd == null || adapters == null) {
|
||||
throw new Error("Impossible.");
|
||||
}
|
||||
|
||||
|
@ -136,7 +133,7 @@ export class PagerankTable extends React.PureComponent<Props, State> {
|
|||
}
|
||||
return (
|
||||
<label>
|
||||
Filter by contribution type:{" "}
|
||||
<span>Filter by node type: </span>
|
||||
<select
|
||||
value={this.state.topLevelFilter}
|
||||
onChange={(e) => {
|
||||
|
@ -151,11 +148,12 @@ export class PagerankTable extends React.PureComponent<Props, State> {
|
|||
}
|
||||
|
||||
renderTable() {
|
||||
const {graph, pagerankResult, adapters} = this.props;
|
||||
if (graph == null || pagerankResult == null || adapters == null) {
|
||||
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={{
|
||||
|
@ -168,23 +166,17 @@ export class PagerankTable extends React.PureComponent<Props, State> {
|
|||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{textAlign: "left"}}>Node</th>
|
||||
<th style={{textAlign: "right"}}>log(score)</th>
|
||||
<th style={{textAlign: "left"}}>Description</th>
|
||||
<th style={{textAlign: "right"}}>Contribution</th>
|
||||
<th style={{textAlign: "right"}}>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<NodesTables
|
||||
addresses={
|
||||
topLevelFilter == null
|
||||
? Array.from(graph.nodes())
|
||||
: Array.from(graph.nodes()).filter((node) =>
|
||||
NodeAddress.hasPrefix(node, topLevelFilter)
|
||||
)
|
||||
}
|
||||
graph={graph}
|
||||
pagerankResult={pagerankResult}
|
||||
depth={0}
|
||||
adapters={adapters}
|
||||
<NodeRowList
|
||||
sharedProps={sharedProps}
|
||||
nodes={Array.from(pnd.keys()).filter((node) =>
|
||||
NodeAddress.hasPrefix(node, topLevelFilter)
|
||||
)}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -192,164 +184,227 @@ export class PagerankTable extends React.PureComponent<Props, State> {
|
|||
}
|
||||
}
|
||||
|
||||
type RTState = {expanded: boolean};
|
||||
type RTProps = {|
|
||||
+node: NodeAddressT,
|
||||
// Present if this RT shows a neighbor (not a top-level node)
|
||||
+edge: ?Edge,
|
||||
+graph: Graph,
|
||||
+pagerankResult: NodeDistribution,
|
||||
+depth: number,
|
||||
+adapters: $ReadOnlyArray<PluginAdapter>,
|
||||
type NodeRowListProps = {|
|
||||
+nodes: $ReadOnlyArray<NodeAddressT>,
|
||||
+sharedProps: SharedProps,
|
||||
|};
|
||||
|
||||
class RecursiveTable extends React.PureComponent<RTProps, RTState> {
|
||||
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, edge, adapters, depth, graph, pagerankResult} = this.props;
|
||||
const {node, sharedProps} = this.props;
|
||||
const {pnd, adapters} = sharedProps;
|
||||
const {expanded} = this.state;
|
||||
const probability = pagerankResult.get(node);
|
||||
if (probability == null) {
|
||||
throw new Error(`no PageRank value for ${NodeAddress.toString(node)}`);
|
||||
}
|
||||
const modifiedLogScore = Math.log(probability) + 10;
|
||||
const edgeVerbString =
|
||||
edge == null ? null : neighborVerb({node, edge}, adapters);
|
||||
return [
|
||||
<tr
|
||||
key="self"
|
||||
style={{backgroundColor: `rgba(0,143.4375,0,${1 - 0.9 ** depth})`}}
|
||||
>
|
||||
<td style={{display: "flex", alignItems: "flex-start"}}>
|
||||
<button
|
||||
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 && (
|
||||
<ContributionRowList
|
||||
key="children"
|
||||
depth={1}
|
||||
node={node}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type ContributionRowListProps = {|
|
||||
+depth: number,
|
||||
+node: NodeAddressT,
|
||||
+sharedProps: SharedProps,
|
||||
|};
|
||||
|
||||
export class ContributionRowList extends React.PureComponent<
|
||||
ContributionRowListProps
|
||||
> {
|
||||
render() {
|
||||
const {depth, node, sharedProps} = this.props;
|
||||
const {pnd, maxEntriesPerList} = sharedProps;
|
||||
const {scoredContributions} = NullUtil.get(pnd.get(node));
|
||||
return (
|
||||
<React.Fragment>
|
||||
{scoredContributions
|
||||
.slice(0, maxEntriesPerList)
|
||||
.map((sc) => (
|
||||
<ContributionRow
|
||||
key={JSON.stringify(sc.contribution.contributor)}
|
||||
depth={depth}
|
||||
target={node}
|
||||
scoredContribution={sc}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type ContributionRowProps = {|
|
||||
+depth: number,
|
||||
+target: NodeAddressT,
|
||||
+scoredContribution: ScoredContribution,
|
||||
+sharedProps: SharedProps,
|
||||
|};
|
||||
|
||||
export class ContributionRow extends React.PureComponent<
|
||||
ContributionRowProps,
|
||||
RowState
|
||||
> {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {expanded: false};
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
sharedProps,
|
||||
target,
|
||||
depth,
|
||||
scoredContribution: {
|
||||
contribution,
|
||||
source,
|
||||
sourceScore,
|
||||
contributionScore,
|
||||
},
|
||||
} = this.props;
|
||||
const {pnd, adapters} = sharedProps;
|
||||
const {expanded} = this.state;
|
||||
const {score: targetScore} = NullUtil.get(pnd.get(target));
|
||||
const contributionProportion = contributionScore / targetScore;
|
||||
const contributionPercent = (contributionProportion * 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>
|
||||
<ContributionView contribution={contribution} adapters={adapters} />
|
||||
</td>
|
||||
<td style={{textAlign: "right"}}>{contributionPercent}%</td>
|
||||
<td style={{textAlign: "right"}}>{scoreDisplay(sourceScore)}</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<ContributionRowList
|
||||
key="children"
|
||||
depth={depth + 1}
|
||||
node={source}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ContributionView extends React.PureComponent<{|
|
||||
+contribution: Contribution,
|
||||
+adapters: $ReadOnlyArray<PluginAdapter>,
|
||||
|}> {
|
||||
render() {
|
||||
const {contribution, 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={{
|
||||
marginRight: 5,
|
||||
marginLeft: 15 * depth,
|
||||
}}
|
||||
onClick={() => {
|
||||
this.setState(({expanded}) => ({
|
||||
expanded: !expanded,
|
||||
}));
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 700,
|
||||
fontSize: "smaller",
|
||||
}}
|
||||
>
|
||||
{expanded ? "\u2212" : "+"}
|
||||
</button>
|
||||
<span>
|
||||
{edgeVerbString != null && (
|
||||
<React.Fragment>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 700,
|
||||
fontSize: "smaller",
|
||||
}}
|
||||
>
|
||||
{edgeVerbString}
|
||||
</span>{" "}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{nodeDescription(node, adapters)}
|
||||
{children}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{textAlign: "right"}}>{modifiedLogScore.toFixed(2)}</td>
|
||||
</tr>,
|
||||
expanded && (
|
||||
<NeighborsTables
|
||||
key="children"
|
||||
neighbors={Array.from(
|
||||
graph.neighbors(node, {
|
||||
direction: Direction.ANY,
|
||||
nodePrefix: NodeAddress.empty,
|
||||
edgePrefix: EdgeAddress.empty,
|
||||
})
|
||||
)}
|
||||
graph={graph}
|
||||
pagerankResult={pagerankResult}
|
||||
depth={depth + 1}
|
||||
adapters={adapters}
|
||||
/>
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
type NodesTablesProps = {|
|
||||
+addresses: $ReadOnlyArray<NodeAddressT>,
|
||||
+graph: Graph,
|
||||
+pagerankResult: NodeDistribution,
|
||||
+depth: number,
|
||||
+adapters: $ReadOnlyArray<PluginAdapter>,
|
||||
|};
|
||||
|
||||
class NodesTables extends React.PureComponent<NodesTablesProps> {
|
||||
render() {
|
||||
const {addresses, graph, pagerankResult, depth, adapters} = this.props;
|
||||
return sortBy(
|
||||
addresses,
|
||||
(x) => {
|
||||
const p = pagerankResult.get(x);
|
||||
if (p == null) {
|
||||
throw new Error(`No pagerank result for ${NodeAddress.toString(x)}`);
|
||||
}
|
||||
return -p;
|
||||
},
|
||||
(x) => x
|
||||
)
|
||||
.slice(0, MAX_TABLE_ENTRIES)
|
||||
.map((address) => (
|
||||
<RecursiveTable
|
||||
depth={depth}
|
||||
node={address}
|
||||
edge={null}
|
||||
graph={graph}
|
||||
pagerankResult={pagerankResult}
|
||||
key={address}
|
||||
adapters={adapters}
|
||||
/>
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
type NeighborsTablesProps = {|
|
||||
+neighbors: $ReadOnlyArray<Neighbor>,
|
||||
+graph: Graph,
|
||||
+pagerankResult: NodeDistribution,
|
||||
+depth: number,
|
||||
+adapters: $ReadOnlyArray<PluginAdapter>,
|
||||
|};
|
||||
|
||||
class NeighborsTables extends React.PureComponent<NeighborsTablesProps> {
|
||||
render() {
|
||||
const {neighbors, graph, pagerankResult, depth, adapters} = this.props;
|
||||
return sortBy(
|
||||
neighbors,
|
||||
({node}) => {
|
||||
const p = pagerankResult.get(node);
|
||||
if (p == null) {
|
||||
throw new Error(
|
||||
`No pagerank result for ${NodeAddress.toString(node)}`
|
||||
);
|
||||
}
|
||||
return -p;
|
||||
},
|
||||
({edge}) => edge
|
||||
)
|
||||
.slice(0, MAX_TABLE_ENTRIES)
|
||||
.map(({node, edge}) => (
|
||||
<RecursiveTable
|
||||
depth={depth}
|
||||
node={node}
|
||||
edge={edge}
|
||||
graph={graph}
|
||||
pagerankResult={pagerankResult}
|
||||
key={edge.address}
|
||||
adapters={adapters}
|
||||
/>
|
||||
));
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const {contributor} = contribution;
|
||||
switch (contributor.type) {
|
||||
case "SYNTHETIC_LOOP":
|
||||
return <Badge>synthetic loop</Badge>;
|
||||
case "IN_EDGE":
|
||||
return (
|
||||
<span>
|
||||
<Badge>
|
||||
{edgeVerb(contributor.edge.address, "BACKWARD", adapters)}
|
||||
</Badge>{" "}
|
||||
<span>{nodeDescription(contributor.edge.src, adapters)}</span>
|
||||
</span>
|
||||
);
|
||||
case "OUT_EDGE":
|
||||
return (
|
||||
<span>
|
||||
<Badge>
|
||||
{edgeVerb(contributor.edge.address, "FORWARD", adapters)}
|
||||
</Badge>{" "}
|
||||
<span>{nodeDescription(contributor.edge.dst, adapters)}</span>
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
throw new Error((contributor.type: empty));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,22 +1,32 @@
|
|||
// @flow
|
||||
import React from "react";
|
||||
import {mount, shallow} from "enzyme";
|
||||
import {shallow} from "enzyme";
|
||||
import enzymeToJSON from "enzyme-to-json";
|
||||
|
||||
import {PagerankTable, nodeDescription, neighborVerb} from "./PagerankTable";
|
||||
import {
|
||||
PagerankTable,
|
||||
NodeRowList,
|
||||
NodeRow,
|
||||
ContributionRowList,
|
||||
ContributionRow,
|
||||
ContributionView,
|
||||
} from "./PagerankTable";
|
||||
import {pagerank} from "../../core/attribution/pagerank";
|
||||
import sortBy from "lodash.sortby";
|
||||
import {type Contribution} from "../../core/attribution/graphToMarkovChain";
|
||||
import * as NullUtil from "../../util/null";
|
||||
|
||||
import {
|
||||
Graph,
|
||||
type NodeAddressT,
|
||||
Direction,
|
||||
NodeAddress,
|
||||
EdgeAddress,
|
||||
} from "../../core/graph";
|
||||
|
||||
require("../testUtil").configureEnzyme();
|
||||
|
||||
const COLUMNS = () => ["Description", "Contribution", "Score"];
|
||||
|
||||
function example() {
|
||||
const graph = new Graph();
|
||||
const nodes = {
|
||||
|
@ -105,21 +115,15 @@ function example() {
|
|||
},
|
||||
];
|
||||
|
||||
const pagerankResult = pagerank(graph, (_unused_Edge) => ({
|
||||
const pnd = pagerank(graph, (_unused_Edge) => ({
|
||||
toWeight: 1,
|
||||
froWeight: 1,
|
||||
}));
|
||||
|
||||
return {adapters, nodes, edges, graph, pagerankResult};
|
||||
return {adapters, nodes, edges, graph, pnd};
|
||||
}
|
||||
|
||||
describe("app/credExplorer/PagerankTable", () => {
|
||||
function verifyNoAdapterWarning() {
|
||||
expect(console.warn).toHaveBeenCalledWith("No adapter for NodeAddress[]");
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
// $ExpectFlowError
|
||||
console.warn = jest.fn();
|
||||
}
|
||||
beforeEach(() => {
|
||||
// $ExpectFlowError
|
||||
console.error = jest.fn();
|
||||
|
@ -131,262 +135,467 @@ describe("app/credExplorer/PagerankTable", () => {
|
|||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("rendering with incomplete props", () => {
|
||||
describe("PagerankTable", () => {
|
||||
it("renders expected message with null props", () => {
|
||||
const element = shallow(
|
||||
<PagerankTable pagerankResult={null} graph={null} adapters={null} />
|
||||
<PagerankTable pnd={null} adapters={null} maxEntriesPerList={1} />
|
||||
);
|
||||
expect(enzymeToJSON(element)).toMatchSnapshot();
|
||||
});
|
||||
it("renders with just pagerankResult", () => {
|
||||
const {pagerankResult} = example();
|
||||
// No snapshot since this should never actually happen
|
||||
shallow(
|
||||
<PagerankTable
|
||||
pagerankResult={pagerankResult}
|
||||
graph={null}
|
||||
adapters={null}
|
||||
/>
|
||||
);
|
||||
});
|
||||
it("renders with just graph", () => {
|
||||
const {graph} = example();
|
||||
// No snapshot since this should never actually happen
|
||||
shallow(
|
||||
<PagerankTable pagerankResult={null} graph={graph} adapters={null} />
|
||||
);
|
||||
});
|
||||
it("renders with just adapters", () => {
|
||||
it("renders expected message with just adapters", () => {
|
||||
const {adapters} = example();
|
||||
// No snapshot since this should never actually happen
|
||||
shallow(
|
||||
<PagerankTable pagerankResult={null} graph={null} adapters={adapters} />
|
||||
);
|
||||
});
|
||||
it("renders expected message when there's no pagerank", () => {
|
||||
const {graph, adapters} = example();
|
||||
const element = shallow(
|
||||
<PagerankTable
|
||||
pagerankResult={null}
|
||||
graph={graph}
|
||||
adapters={adapters}
|
||||
/>
|
||||
<PagerankTable pnd={null} adapters={adapters} maxEntriesPerList={1} />
|
||||
);
|
||||
expect(enzymeToJSON(element)).toMatchSnapshot();
|
||||
});
|
||||
it("throws an error if maxEntriesPerList not set", () => {
|
||||
const {pnd, adapters} = example();
|
||||
expect(() =>
|
||||
shallow(
|
||||
<PagerankTable
|
||||
pnd={pnd}
|
||||
adapters={adapters}
|
||||
// $ExpectFlowError
|
||||
maxEntriesPerList={null}
|
||||
/>
|
||||
)
|
||||
).toThrowError("maxEntriesPerList");
|
||||
});
|
||||
it("renders thead column order properly", () => {
|
||||
const {pnd, adapters} = 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", () => {
|
||||
function setup() {
|
||||
const {pnd, adapters} = 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", () => {
|
||||
const {label} = setup();
|
||||
const filterText = label
|
||||
.find("span")
|
||||
.first()
|
||||
.text();
|
||||
expect(filterText).toMatchSnapshot();
|
||||
});
|
||||
it("with expected option groups", () => {
|
||||
const {options} = 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", () => {
|
||||
const {element, options} = 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", () => {
|
||||
function setup() {
|
||||
const {adapters, pnd} = 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", () => {
|
||||
const {nrl, adapters, pnd, maxEntriesPerList} = setup();
|
||||
const expectedSharedProps = {adapters, pnd, maxEntriesPerList};
|
||||
expect(nrl.prop("sharedProps")).toEqual(expectedSharedProps);
|
||||
});
|
||||
it("including all nodes by default", () => {
|
||||
const {nrl, pnd} = setup();
|
||||
const expectedNodes = Array.from(pnd.keys());
|
||||
expect(nrl.prop("nodes")).toEqual(expectedNodes);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("full rendering", () => {
|
||||
function exampleRender() {
|
||||
const {nodes, edges, adapters, graph, pagerankResult} = example();
|
||||
const element = mount(
|
||||
<PagerankTable
|
||||
pagerankResult={pagerankResult}
|
||||
graph={graph}
|
||||
adapters={adapters}
|
||||
describe("NodeRowList", () => {
|
||||
function sortedByScore(nodes: $ReadOnlyArray<NodeAddressT>, pnd) {
|
||||
return sortBy(nodes, (node) => -NullUtil.get(pnd.get(node)).score);
|
||||
}
|
||||
function setup(maxEntriesPerList: number = 100000) {
|
||||
const {adapters, pnd} = 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", () => {
|
||||
const {element, nodes, sharedProps} = 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", () => {
|
||||
const maxEntriesPerList = 1;
|
||||
const {element, nodes, sharedProps} = 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", () => {
|
||||
const {
|
||||
element,
|
||||
nodes,
|
||||
sharedProps: {pnd},
|
||||
} = 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", () => {
|
||||
function setup() {
|
||||
const {pnd, adapters, nodes} = 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", () => {
|
||||
expect(setup().element.find("td")).toHaveLength(COLUMNS().length);
|
||||
});
|
||||
it("renders the node description", () => {
|
||||
const {element} = 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 contribution column", () => {
|
||||
const {element} = setup();
|
||||
const contributionColumn = COLUMNS().indexOf("Contribution");
|
||||
expect(contributionColumn).not.toEqual(-1);
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(contributionColumn)
|
||||
.text()
|
||||
).toEqual("—");
|
||||
});
|
||||
it("renders a score column with the node's log-score", () => {
|
||||
const {element, sharedProps, node} = setup();
|
||||
const {score: rawScore} = NullUtil.get(sharedProps.pnd.get(node));
|
||||
const expectedScore = (Math.log(rawScore) + 10).toFixed(2);
|
||||
const contributionColumn = COLUMNS().indexOf("Score");
|
||||
expect(contributionColumn).not.toEqual(-1);
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(contributionColumn)
|
||||
.text()
|
||||
).toEqual(expectedScore);
|
||||
});
|
||||
it("does not render children by default", () => {
|
||||
const {element} = setup();
|
||||
expect(element.find("ContributionRowList")).toHaveLength(0);
|
||||
});
|
||||
it('has a working "expand" button', () => {
|
||||
const {element, sharedProps, node} = setup();
|
||||
expect(element.find("button").text()).toEqual("+");
|
||||
|
||||
element.find("button").simulate("click");
|
||||
expect(element.find("button").text()).toEqual("\u2212");
|
||||
const crl = element.find("ContributionRowList");
|
||||
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("ContributionRowList")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContributionRowList", () => {
|
||||
function setup(maxEntriesPerList: number = 100000) {
|
||||
const {adapters, pnd, nodes} = example();
|
||||
const depth = 2;
|
||||
const node = nodes.bar1;
|
||||
const sharedProps = {adapters, pnd, maxEntriesPerList};
|
||||
const component = (
|
||||
<ContributionRowList
|
||||
depth={depth}
|
||||
node={node}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
);
|
||||
verifyNoAdapterWarning();
|
||||
const select = element.find("select");
|
||||
expect(select).toHaveLength(1);
|
||||
return {nodes, edges, adapters, graph, pagerankResult, element, select};
|
||||
const element = shallow(component);
|
||||
return {element, depth, node, sharedProps};
|
||||
}
|
||||
it("full render doesn't crash or error", () => {
|
||||
example();
|
||||
it("creates `ContributionRow`s with the right props", () => {
|
||||
const {element, depth, node, sharedProps} = setup();
|
||||
const contributions = NullUtil.get(sharedProps.pnd.get(node))
|
||||
.scoredContributions;
|
||||
const rows = element.find("ContributionRow");
|
||||
expect(rows).toHaveLength(contributions.length);
|
||||
const rowPropses = rows.map((row) => row.props());
|
||||
// Order should be the same as the order in the decomposition.
|
||||
expect(rowPropses).toEqual(
|
||||
contributions.map((sc) => ({
|
||||
depth,
|
||||
sharedProps,
|
||||
target: node,
|
||||
scoredContribution: sc,
|
||||
}))
|
||||
);
|
||||
});
|
||||
it("limits the number of rows by `maxEntriesPerList`", () => {
|
||||
const maxEntriesPerList = 1;
|
||||
const {element, node, sharedProps} = setup(maxEntriesPerList);
|
||||
const contributions = NullUtil.get(sharedProps.pnd.get(node))
|
||||
.scoredContributions;
|
||||
expect(contributions.length).toBeGreaterThan(maxEntriesPerList);
|
||||
const rows = element.find("ContributionRow");
|
||||
expect(rows).toHaveLength(maxEntriesPerList);
|
||||
const rowContributions = rows.map((row) =>
|
||||
row.prop("scoredContribution")
|
||||
);
|
||||
// Should have selected the right nodes.
|
||||
expect(rowContributions).toEqual(
|
||||
contributions.slice(0, maxEntriesPerList)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tables", () => {
|
||||
function expectColumnCorrect(
|
||||
element: *,
|
||||
name: string,
|
||||
tdToExpected: (x: *) => string,
|
||||
addressToExpected: (NodeAddressT) => string
|
||||
) {
|
||||
const header = element.find("th");
|
||||
const headerTexts = header.map((x) => x.text());
|
||||
const headerIndex = headerTexts.indexOf(name);
|
||||
if (headerIndex === -1) {
|
||||
throw new Error("Could not find column: " + name);
|
||||
}
|
||||
const tables = element.find("RecursiveTable");
|
||||
const actual = tables.map((x) =>
|
||||
tdToExpected(x.find("td").at(headerIndex))
|
||||
describe("ContributionRow", () => {
|
||||
function setup() {
|
||||
const {pnd, adapters, nodes} = example();
|
||||
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
|
||||
const target = nodes.bar1;
|
||||
const {scoredContributions} = NullUtil.get(pnd.get(target));
|
||||
const alphaContributions = scoredContributions.filter(
|
||||
(sc) => sc.source === nodes.fooAlpha
|
||||
);
|
||||
expect(alphaContributions).toHaveLength(1);
|
||||
const contribution = alphaContributions[0];
|
||||
const {source} = contribution;
|
||||
const depth = 2;
|
||||
const component = (
|
||||
<ContributionRow
|
||||
depth={depth}
|
||||
target={target}
|
||||
scoredContribution={contribution}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
);
|
||||
const element = shallow(component);
|
||||
return {element, depth, target, source, contribution, sharedProps};
|
||||
}
|
||||
it("renders the right number of columns", () => {
|
||||
expect(setup().element.find("td")).toHaveLength(COLUMNS().length);
|
||||
});
|
||||
it("has proper depth-based styling", () => {
|
||||
const {element} = setup();
|
||||
expect({
|
||||
buttonStyle: element.find("button").prop("style"),
|
||||
trStyle: element.find("tr").prop("style"),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
it("renders the source view", () => {
|
||||
const {element, sharedProps, contribution} = setup();
|
||||
const descriptionColumn = COLUMNS().indexOf("Description");
|
||||
expect(descriptionColumn).not.toEqual(-1);
|
||||
const view = element
|
||||
.find("td")
|
||||
.at(descriptionColumn)
|
||||
.find("ContributionView");
|
||||
expect(view).toHaveLength(1);
|
||||
expect(view.props()).toEqual({
|
||||
adapters: sharedProps.adapters,
|
||||
contribution: contribution.contribution,
|
||||
});
|
||||
});
|
||||
it("renders the contribution percentage", () => {
|
||||
const {element, contribution, sharedProps, target} = setup();
|
||||
const contributionColumn = COLUMNS().indexOf("Contribution");
|
||||
expect(contributionColumn).not.toEqual(-1);
|
||||
const proportion =
|
||||
contribution.contributionScore /
|
||||
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(contributionColumn)
|
||||
.text()
|
||||
).toEqual(expectedText);
|
||||
});
|
||||
it("renders a score column with the source's log-score", () => {
|
||||
const {element, contribution} = setup();
|
||||
const expectedScore = (Math.log(contribution.sourceScore) + 10).toFixed(
|
||||
2
|
||||
);
|
||||
const contributionColumn = COLUMNS().indexOf("Score");
|
||||
expect(contributionColumn).not.toEqual(-1);
|
||||
expect(
|
||||
element
|
||||
.find("td")
|
||||
.at(contributionColumn)
|
||||
.text()
|
||||
).toEqual(expectedScore);
|
||||
});
|
||||
it("does not render children by default", () => {
|
||||
const {element} = setup();
|
||||
expect(element.find("ContributionRowList")).toHaveLength(0);
|
||||
});
|
||||
it('has a working "expand" button', () => {
|
||||
const {element, depth, sharedProps, source} = setup();
|
||||
expect(element.find("button").text()).toEqual("+");
|
||||
|
||||
element.find("button").simulate("click");
|
||||
expect(element.find("button").text()).toEqual("\u2212");
|
||||
const crl = element.find("ContributionRowList");
|
||||
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("ContributionRowList")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContributionView", () => {
|
||||
function setup() {
|
||||
const {pnd, adapters, nodes} = example();
|
||||
const {scoredContributions} = NullUtil.get(pnd.get(nodes.bar1));
|
||||
const contributions = scoredContributions.map((sc) => sc.contribution);
|
||||
function contributionByType(t) {
|
||||
return NullUtil.get(
|
||||
contributions.filter((c) => c.contributor.type === t)[0],
|
||||
`Couldn't find contribution for type ${t}`
|
||||
);
|
||||
const expected = tables.map((x) => addressToExpected(x.prop("node")));
|
||||
expect(actual).toEqual(expected);
|
||||
}
|
||||
describe("top-level", () => {
|
||||
it("are sorted by score", () => {
|
||||
const {element, graph, pagerankResult} = exampleRender();
|
||||
const rows = element.find("RecursiveTable");
|
||||
expect(rows).toHaveLength(Array.from(graph.nodes()).length);
|
||||
const scores = rows.map((x) => pagerankResult.get(x.prop("node")));
|
||||
expect(scores.every((x) => x != null)).toBe(true);
|
||||
expect(scores).toEqual(sortBy(scores).reverse());
|
||||
});
|
||||
it("has a node description column", () => {
|
||||
const {element, adapters} = exampleRender();
|
||||
expectColumnCorrect(
|
||||
element,
|
||||
"Node",
|
||||
(td) => td.find("span").text(),
|
||||
(address) => nodeDescription(address, adapters)
|
||||
);
|
||||
verifyNoAdapterWarning();
|
||||
});
|
||||
it("has a log score column", () => {
|
||||
const {element, pagerankResult} = exampleRender();
|
||||
expectColumnCorrect(
|
||||
element,
|
||||
"log(score)",
|
||||
(td) => td.text(),
|
||||
(address) => {
|
||||
const probability = pagerankResult.get(address);
|
||||
if (probability == null) {
|
||||
throw new Error(address);
|
||||
}
|
||||
const modifiedLogScore = Math.log(probability) + 10;
|
||||
return modifiedLogScore.toFixed(2);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("sub-tables", () => {
|
||||
it("have depth-based styling", () => {
|
||||
const {element} = exampleRender();
|
||||
const getLevel = (level) => {
|
||||
const rt = element.find("RecursiveTable").at(level);
|
||||
const button = rt.find("button").first();
|
||||
return {rt, button};
|
||||
};
|
||||
getLevel(0).button.simulate("click");
|
||||
getLevel(1).button.simulate("click");
|
||||
const f = ({rt, button}) => ({
|
||||
row: rt
|
||||
.find("tr")
|
||||
.first()
|
||||
.prop("style"),
|
||||
button: button.prop("style"),
|
||||
});
|
||||
expect([0, 1, 2].map((x) => f(getLevel(x)))).toMatchSnapshot();
|
||||
});
|
||||
it("display extra information about edges", () => {
|
||||
const {element, nodes, graph, adapters} = exampleRender();
|
||||
const getLevel = (level) => {
|
||||
const rt = element.find("RecursiveTable").at(level);
|
||||
const button = rt.find("button").first();
|
||||
return {rt, button};
|
||||
};
|
||||
getLevel(0).button.simulate("click");
|
||||
const nt = element.find("NeighborsTables");
|
||||
expect(nt).toHaveLength(1);
|
||||
const expectedNeighbors = Array.from(
|
||||
graph.neighbors(nodes.bar1, {
|
||||
direction: Direction.ANY,
|
||||
nodePrefix: NodeAddress.empty,
|
||||
edgePrefix: EdgeAddress.empty,
|
||||
})
|
||||
);
|
||||
expect(nt.prop("neighbors")).toEqual(expectedNeighbors);
|
||||
const subTables = nt.find("RecursiveTable");
|
||||
expect(subTables).toHaveLength(expectedNeighbors.length);
|
||||
const actualEdgeVerbs = subTables.map((x) =>
|
||||
x
|
||||
.find("span")
|
||||
.children()
|
||||
.find("span")
|
||||
.text()
|
||||
);
|
||||
const expectedEdgeVerbs = subTables.map((x) => {
|
||||
const edge = x.prop("edge");
|
||||
const node = x.prop("node");
|
||||
return neighborVerb({edge, node}, adapters);
|
||||
});
|
||||
|
||||
expect(actualEdgeVerbs).toEqual(expectedEdgeVerbs);
|
||||
const actualFullDescriptions = subTables.map((x) =>
|
||||
x
|
||||
.find("span")
|
||||
.first()
|
||||
.text()
|
||||
);
|
||||
const expectedFullDescriptions = subTables.map((x) => {
|
||||
const edge = x.prop("edge");
|
||||
const node = x.prop("node");
|
||||
const nd = nodeDescription(node, adapters);
|
||||
const ev = neighborVerb({node, edge}, adapters);
|
||||
return `${ev} ${nd}`;
|
||||
});
|
||||
expect(actualFullDescriptions).toEqual(expectedFullDescriptions);
|
||||
expect(actualFullDescriptions).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
it("button toggles between +/- and adds sub-RecursiveTable", () => {
|
||||
const {element} = exampleRender();
|
||||
const rt = () => element.find("RecursiveTable").first();
|
||||
const button = rt().find("button");
|
||||
expect(button).toEqual(expect.anything());
|
||||
expect(button.text()).toEqual("+");
|
||||
expect(rt().find("NeighborsTables")).toHaveLength(0);
|
||||
|
||||
button.simulate("click");
|
||||
expect(button.text()).toEqual("\u2212");
|
||||
expect(rt().find("NeighborsTables")).toHaveLength(1);
|
||||
|
||||
button.simulate("click");
|
||||
expect(button.text()).toEqual("+");
|
||||
expect(rt().find("NeighborsTables")).toHaveLength(0);
|
||||
});
|
||||
const inContribution = contributionByType("IN_EDGE");
|
||||
const outContribution = contributionByType("OUT_EDGE");
|
||||
const syntheticContribution = contributionByType("SYNTHETIC_LOOP");
|
||||
function cvForContribution(contribution: Contribution) {
|
||||
return shallow(
|
||||
<ContributionView adapters={adapters} contribution={contribution} />
|
||||
);
|
||||
}
|
||||
return {
|
||||
adapters,
|
||||
contributions,
|
||||
pnd,
|
||||
cvForContribution,
|
||||
inContribution,
|
||||
outContribution,
|
||||
syntheticContribution,
|
||||
};
|
||||
}
|
||||
it("always renders exactly one `Badge`", () => {
|
||||
const {
|
||||
cvForContribution,
|
||||
inContribution,
|
||||
outContribution,
|
||||
syntheticContribution,
|
||||
} = setup();
|
||||
for (const contribution of [
|
||||
syntheticContribution,
|
||||
inContribution,
|
||||
outContribution,
|
||||
]) {
|
||||
expect(cvForContribution(contribution).find("Badge")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
describe("filter selector", () => {
|
||||
it("has the correct options", () => {
|
||||
const {select} = exampleRender();
|
||||
const options = select.children();
|
||||
expect(options.every("option")).toBe(true);
|
||||
const results = options.map((x) => ({
|
||||
valueString: NodeAddress.toString(x.prop("value")),
|
||||
style: x.prop("style"),
|
||||
text: x.text(),
|
||||
}));
|
||||
expect(results).toMatchSnapshot();
|
||||
});
|
||||
|
||||
function selectFilterByName(select, name) {
|
||||
const option = select.children().filterWhere((x) => x.text() === name);
|
||||
if (option.length !== 1) {
|
||||
throw new Error(`ambiguous select, got ${option.length} options`);
|
||||
}
|
||||
const value = option.prop("value");
|
||||
select.simulate("change", {target: {value}});
|
||||
}
|
||||
it("plugin-level filter with no nodes works", () => {
|
||||
const {select, element} = exampleRender();
|
||||
expect(element.find("tbody tr")).not.toHaveLength(0);
|
||||
selectFilterByName(select, "unused");
|
||||
expect(element.find("tbody tr")).toHaveLength(0);
|
||||
});
|
||||
it("type-level filter with some nodes works", () => {
|
||||
const {select, element} = exampleRender();
|
||||
selectFilterByName(select, "\u2003beta");
|
||||
const rt = element.find("RecursiveTable");
|
||||
expect(rt).toHaveLength(1);
|
||||
expect(rt.prop("node")).toEqual(example().nodes.fooBeta);
|
||||
});
|
||||
it("filter doesn't apply to sub-tables", () => {
|
||||
const {select, element} = exampleRender();
|
||||
selectFilterByName(select, "\u2003beta");
|
||||
const rt = element.find("RecursiveTable");
|
||||
expect(rt).toHaveLength(1);
|
||||
const button = rt.find("button");
|
||||
expect(button).toHaveLength(1);
|
||||
button.simulate("click");
|
||||
|
||||
const rts = element.find("RecursiveTable");
|
||||
expect(rts).toHaveLength(2);
|
||||
const subRt = rts.last();
|
||||
expect(subRt.prop("node")).toEqual(example().nodes.fooAlpha);
|
||||
});
|
||||
it("for inward contributions, renders a `Badge` and description", () => {
|
||||
const {cvForContribution, inContribution} = setup();
|
||||
const view = cvForContribution(inContribution);
|
||||
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 contributions, renders a `Badge` and description", () => {
|
||||
const {cvForContribution, outContribution} = setup();
|
||||
const view = cvForContribution(outContribution);
|
||||
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 contributions, renders only a `Badge`", () => {
|
||||
const {cvForContribution, syntheticContribution} = setup();
|
||||
const view = cvForContribution(syntheticContribution);
|
||||
expect(view.find("span")).toHaveLength(0);
|
||||
expect(
|
||||
view
|
||||
.find("Badge")
|
||||
.children()
|
||||
.text()
|
||||
).toEqual("synthetic loop");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,20 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`app/credExplorer/PagerankTable full rendering filter selector has the correct options 1`] = `
|
||||
exports[`app/credExplorer/PagerankTable ContributionRow 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 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`] = `
|
||||
Array [
|
||||
Object {
|
||||
"style": undefined,
|
||||
|
@ -53,54 +67,13 @@ Array [
|
|||
]
|
||||
`;
|
||||
|
||||
exports[`app/credExplorer/PagerankTable full rendering tables sub-tables display extra information about edges 1`] = `
|
||||
Array [
|
||||
"bars and is barred by bar: NodeAddress[\\"bar\\",\\"a\\",\\"1\\"]",
|
||||
"bars xox node!",
|
||||
"bars xox node!",
|
||||
"is fooed by foo: NodeAddress[\\"foo\\",\\"a\\",\\"1\\"]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`app/credExplorer/PagerankTable full rendering tables sub-tables have depth-based styling 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"button": Object {
|
||||
"marginLeft": 0,
|
||||
"marginRight": 5,
|
||||
},
|
||||
"row": Object {
|
||||
"backgroundColor": "rgba(0,143.4375,0,0)",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"button": Object {
|
||||
"marginLeft": 15,
|
||||
"marginRight": 5,
|
||||
},
|
||||
"row": Object {
|
||||
"backgroundColor": "rgba(0,143.4375,0,0.09999999999999998)",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"button": Object {
|
||||
"marginLeft": 30,
|
||||
"marginRight": 5,
|
||||
},
|
||||
"row": Object {
|
||||
"backgroundColor": "rgba(0,143.4375,0,0.18999999999999995)",
|
||||
},
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`app/credExplorer/PagerankTable rendering with incomplete props renders expected message when there's no pagerank 1`] = `
|
||||
exports[`app/credExplorer/PagerankTable PagerankTable renders expected message with just adapters 1`] = `
|
||||
<p>
|
||||
Please run PageRank to see analysis.
|
||||
</p>
|
||||
`;
|
||||
|
||||
exports[`app/credExplorer/PagerankTable rendering with incomplete props renders expected message with null props 1`] = `
|
||||
exports[`app/credExplorer/PagerankTable PagerankTable renders expected message with null props 1`] = `
|
||||
<p>
|
||||
You must load a graph before seeing PageRank analysis.
|
||||
</p>
|
||||
|
|
|
@ -1,59 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`core/attribution/pagerank respects explicit arguments 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"parts": Array [
|
||||
"dst",
|
||||
],
|
||||
"probability": 0.25,
|
||||
},
|
||||
Object {
|
||||
"parts": Array [
|
||||
"isolated",
|
||||
],
|
||||
"probability": 0.25,
|
||||
},
|
||||
Object {
|
||||
"parts": Array [
|
||||
"loop",
|
||||
],
|
||||
"probability": 0.25,
|
||||
},
|
||||
Object {
|
||||
"parts": Array [
|
||||
"src",
|
||||
],
|
||||
"probability": 0.25,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`core/attribution/pagerank snapshots as expected on the advanced graph 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"parts": Array [
|
||||
"dst",
|
||||
],
|
||||
"probability": 0.4999999999687968,
|
||||
},
|
||||
Object {
|
||||
"parts": Array [
|
||||
"isolated",
|
||||
],
|
||||
"probability": 0.25,
|
||||
},
|
||||
Object {
|
||||
"parts": Array [
|
||||
"loop",
|
||||
],
|
||||
"probability": 0.25000000000000017,
|
||||
},
|
||||
Object {
|
||||
"parts": Array [
|
||||
"src",
|
||||
],
|
||||
"probability": 3.120317183596679e-11,
|
||||
},
|
||||
]
|
||||
`;
|
|
@ -2,16 +2,20 @@
|
|||
|
||||
import {type Edge, Graph} from "../graph";
|
||||
import {
|
||||
type NodeDistribution,
|
||||
distributionToNodeDistribution,
|
||||
createContributions,
|
||||
createOrderedSparseMarkovChain,
|
||||
type EdgeWeight,
|
||||
} from "./graphToMarkovChain";
|
||||
import {
|
||||
decompose,
|
||||
type PagerankNodeDecomposition,
|
||||
} from "./pagerankNodeDecomposition";
|
||||
|
||||
import {findStationaryDistribution} from "./markovChain";
|
||||
|
||||
export type {NodeDistribution} from "./graphToMarkovChain";
|
||||
export type {PagerankNodeDecomposition} from "./pagerankNodeDecomposition";
|
||||
export type PagerankOptions = {|
|
||||
+selfLoopWeight?: number,
|
||||
+verbose?: boolean,
|
||||
|
@ -35,7 +39,7 @@ export function pagerank(
|
|||
graph: Graph,
|
||||
edgeWeight: EdgeEvaluator,
|
||||
options?: PagerankOptions
|
||||
): NodeDistribution {
|
||||
): PagerankNodeDecomposition {
|
||||
const fullOptions = {
|
||||
...defaultOptions(),
|
||||
...(options || {}),
|
||||
|
@ -51,5 +55,6 @@ export function pagerank(
|
|||
convergenceThreshold: fullOptions.convergenceThreshold,
|
||||
maxIterations: fullOptions.maxIterations,
|
||||
});
|
||||
return distributionToNodeDistribution(osmc.nodeOrder, distribution);
|
||||
const pi = distributionToNodeDistribution(osmc.nodeOrder, distribution);
|
||||
return decompose(pi, contributions);
|
||||
}
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
// @flow
|
||||
|
||||
import {pagerank} from "./pagerank";
|
||||
import {NodeAddress} from "../graph";
|
||||
import {advancedGraph} from "../graphTestUtil";
|
||||
|
||||
function snapshotPagerankResult(result) {
|
||||
const prTotal = Array.from(result.values()).reduce((a, b) => a + b, 0);
|
||||
expect(prTotal).toBeCloseTo(1.0, 1e-9);
|
||||
const partsToProbability = [];
|
||||
const sortedKeys = Array.from(result.keys()).sort();
|
||||
for (const key of sortedKeys) {
|
||||
const probability = result.get(key);
|
||||
const parts = NodeAddress.toParts((key: any));
|
||||
partsToProbability.push({parts, probability});
|
||||
}
|
||||
expect(partsToProbability).toMatchSnapshot();
|
||||
}
|
||||
|
||||
describe("core/attribution/pagerank", () => {
|
||||
function edgeWeight(_unused_edge) {
|
||||
return {toWeight: 1, froWeight: 0};
|
||||
}
|
||||
it("snapshots as expected on the advanced graph", () => {
|
||||
const pagerankResult = pagerank(advancedGraph().graph1(), edgeWeight);
|
||||
snapshotPagerankResult(pagerankResult);
|
||||
});
|
||||
it("respects explicit arguments", () => {
|
||||
const pagerankResult = pagerank(advancedGraph().graph1(), edgeWeight, {
|
||||
maxIterations: 0,
|
||||
});
|
||||
snapshotPagerankResult(pagerankResult);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue