From 5960eab6c1967ee92ad0de771f76739f6e34dc76 Mon Sep 17 00:00:00 2001 From: William Chargin Date: Mon, 5 Mar 2018 16:22:58 -0800 Subject: [PATCH] Make `Graph` serializable (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: This commit adds `toJSON()` and `static fromJSON()` on `Graph`. The main benefit at this time is that this gets us free interoperability with Jest’s snapshot testing. The implementation of `fromJSON` is not performance-tuned, and could probably be significantly optimized. See #65 for discussion. Test Plan: New unit tests added: `yarn flow && yarn test`. wchargin-branch: make-graph-serializable --- src/backend/__snapshots__/graph.test.js.snap | 138 +++++++++++++++++++ src/backend/graph.js | 29 +++- src/backend/graph.test.js | 39 ++++++ 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 src/backend/__snapshots__/graph.test.js.snap diff --git a/src/backend/__snapshots__/graph.test.js.snap b/src/backend/__snapshots__/graph.test.js.snap new file mode 100644 index 0000000..1997303 --- /dev/null +++ b/src/backend/__snapshots__/graph.test.js.snap @@ -0,0 +1,138 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`graph #Graph JSON functions should serialize a simple graph 1`] = ` +Object { + "edges": Object { + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"crab-self-assessment\\"}": Object { + "dst": Object { + "id": "razorclaw_crab#2", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + "payload": Object { + "evaluation": "not effective at avoiding hero", + }, + "src": Object { + "id": "razorclaw_crab#2", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + }, + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0@again_cooks@seafood_fruit_mix#3\\"}": Object { + "dst": Object { + "id": "hero_of_time#0", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + "payload": Object { + "crit": true, + "saveScummed": true, + }, + "src": Object { + "id": "seafood_fruit_mix#3", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + }, + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0@cooks@seafood_fruit_mix#3\\"}": Object { + "dst": Object { + "id": "hero_of_time#0", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + "payload": Object { + "crit": false, + }, + "src": Object { + "id": "seafood_fruit_mix#3", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + }, + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0@eats@seafood_fruit_mix#3\\"}": Object { + "dst": Object { + "id": "seafood_fruit_mix#3", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + "payload": Object {}, + "src": Object { + "id": "hero_of_time#0", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + }, + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0@grabs@razorclaw_crab#2\\"}": Object { + "dst": Object { + "id": "hero_of_time#0", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + "payload": Object {}, + "src": Object { + "id": "razorclaw_crab#2", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + }, + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0@picks@mighty_bananas#1\\"}": Object { + "dst": Object { + "id": "hero_of_time#0", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + "payload": Object {}, + "src": Object { + "id": "mighty_bananas#1", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + }, + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"mighty_bananas#1@included_in@seafood_fruit_mix#3\\"}": Object { + "dst": Object { + "id": "mighty_bananas#1", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + "payload": Object {}, + "src": Object { + "id": "seafood_fruit_mix#3", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + }, + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"razorclaw_crab#2@included_in@seafood_fruit_mix#3\\"}": Object { + "dst": Object { + "id": "razorclaw_crab#2", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + "payload": Object {}, + "src": Object { + "id": "seafood_fruit_mix#3", + "pluginName": "hill_cooking_pot", + "repositoryName": "sourcecred/eventide", + }, + }, + }, + "nodes": Object { + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0\\"}": Object { + "payload": Object {}, + }, + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"mighty_bananas#1\\"}": Object { + "payload": Object {}, + }, + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"razorclaw_crab#2\\"}": Object { + "payload": Object {}, + }, + "{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"seafood_fruit_mix#3\\"}": Object { + "payload": Object { + "effect": Array [ + "attack_power", + 1, + ], + }, + }, + }, +} +`; diff --git a/src/backend/graph.js b/src/backend/graph.js index df55853..16873e0 100644 --- a/src/backend/graph.js +++ b/src/backend/graph.js @@ -1,7 +1,7 @@ // @flow import deepEqual from "lodash.isequal"; -import type {Address, Addressable} from "./address"; +import type {Address, Addressable, AddressMapJSON} from "./address"; import {AddressMap} from "./address"; export type Node = {| @@ -16,6 +16,11 @@ export type Edge = {| +payload: T, |}; +export type GraphJSON = {| + +nodes: AddressMapJSON>, + +edges: AddressMapJSON>, +|}; + export class Graph { _nodes: AddressMap>; _edges: AddressMap>; @@ -39,6 +44,28 @@ export class Graph { return this._nodes.equals(that._nodes) && this._edges.equals(that._edges); } + toJSON(): GraphJSON { + return { + nodes: this._nodes.toJSON(), + edges: this._edges.toJSON(), + }; + } + + static fromJSON(json: GraphJSON): Graph { + const result = new Graph(); + AddressMap.fromJSON(json.nodes) + .getAll() + .forEach((node) => { + result.addNode(node); + }); + AddressMap.fromJSON(json.edges) + .getAll() + .forEach((edge) => { + result.addEdge(edge); + }); + return result; + } + addNode(node: Node) { if (node == null) { throw new Error(`node is ${String(node)}`); diff --git a/src/backend/graph.test.js b/src/backend/graph.test.js index eb1571f..042cc95 100644 --- a/src/backend/graph.test.js +++ b/src/backend/graph.test.js @@ -555,5 +555,44 @@ describe("graph", () => { expect(merged.equals(new Graph())).toBe(true); }); }); + + describe("JSON functions", () => { + it("should serialize a simple graph", () => { + expect(advancedMealGraph().toJSON()).toMatchSnapshot(); + }); + it("should work transparently with JSON.stringify", () => { + // (This is guaranteed by the `JSON.stringify` API, and is more + // as documentation than actual test.) + expect(JSON.stringify(advancedMealGraph())).toEqual( + JSON.stringify(advancedMealGraph().toJSON()) + ); + }); + it("should canonicalize away node insertion order", () => { + const g1 = new Graph().addNode(heroNode()).addNode(mealNode()); + const g2 = new Graph().addNode(mealNode()).addNode(heroNode()); + expect(g1.toJSON()).toEqual(g2.toJSON()); + }); + it("should canonicalize away edge insertion order", () => { + const g1 = new Graph() + .addNode(heroNode()) + .addNode(mealNode()) + .addEdge(cookEdge()) + .addEdge(duplicateCookEdge()); + const g2 = new Graph() + .addNode(heroNode()) + .addNode(mealNode()) + .addEdge(duplicateCookEdge()) + .addEdge(cookEdge()); + expect(g1.toJSON()).toEqual(g2.toJSON()); + }); + it("should no-op on a serialization--deserialization roundtrip", () => { + const g = () => advancedMealGraph(); + expect(Graph.fromJSON(g().toJSON()).equals(g())).toBe(true); + }); + it("should no-op on a deserialization--serialization roundtrip", () => { + const json = () => advancedMealGraph().toJSON(); + expect(Graph.fromJSON(json()).toJSON()).toEqual(json()); + }); + }); }); });