Test suite: pytest unit and integration tests, node tests, CI workflow
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""Workflow patches: apply, link repair, conflict renumbering, JS parity."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import ROOT
|
||||
from rigshare_core.graphdoc import apply_patch, reconcile, remap_conflicts, link_id, link_ends
|
||||
|
||||
|
||||
def node(nid, n_in=2, n_out=1):
|
||||
return {"id": nid, "type": "T", "pos": [0, 0],
|
||||
"inputs": [{"name": f"i{i}", "link": None} for i in range(n_in)],
|
||||
"outputs": [{"name": f"o{j}", "links": []} for j in range(n_out)]}
|
||||
|
||||
|
||||
def doc_with(n=3, links=()):
|
||||
doc = {"last_node_id": n, "last_link_id": len(links), "nodes": [node(i + 1) for i in range(n)],
|
||||
"links": [list(l) for l in links], "groups": [], "extra": {}, "version": 0.4}
|
||||
return reconcile(doc)
|
||||
|
||||
|
||||
def assert_consistent(doc):
|
||||
ids = {n["id"] for n in doc["nodes"]}
|
||||
links = {link_id(l): l for l in doc["links"]}
|
||||
assert len(links) == len(doc["links"]), "duplicate link ids"
|
||||
assert len(ids) == len(doc["nodes"]), "duplicate node ids"
|
||||
for l in doc["links"]:
|
||||
origin, _, target, _ = link_ends(l)
|
||||
assert origin in ids and target in ids, "dangling link"
|
||||
for n in doc["nodes"]:
|
||||
for i, slot in enumerate(n["inputs"]):
|
||||
if slot["link"] is not None:
|
||||
_, _, target, target_slot = link_ends(links[slot["link"]])
|
||||
assert (target, target_slot) == (n["id"], i)
|
||||
|
||||
|
||||
def test_upsert_and_remove_nodes():
|
||||
doc = doc_with(3)
|
||||
moved = copy.deepcopy(doc["nodes"][0]); moved["pos"] = [5, 5]
|
||||
out = apply_patch(doc, {"nodes": [moved, node(4)], "removed": [2]})
|
||||
assert [n["id"] for n in out["nodes"]] == [1, 3, 4]
|
||||
assert out["nodes"][0]["pos"] == [5, 5]
|
||||
assert doc["nodes"][0]["pos"] == [0, 0], "input document must not be mutated"
|
||||
|
||||
|
||||
def test_links_merge_by_id():
|
||||
doc = doc_with(3, [(1, 1, 0, 2, 0, "X")])
|
||||
out = apply_patch(doc, {"links": {"upsert": [[2, 3, 0, 2, 1, "X"]]}})
|
||||
assert sorted(link_id(l) for l in out["links"]) == [1, 2], "a new link must not erase another"
|
||||
|
||||
|
||||
def test_max_keys_never_go_backwards():
|
||||
doc = doc_with(3)
|
||||
doc["last_node_id"] = 10
|
||||
assert apply_patch(doc, {"set": {"last_node_id": 4}})["last_node_id"] == 10
|
||||
|
||||
|
||||
def test_reconcile_drops_dangling_and_duplicate_inputs():
|
||||
doc = doc_with(3)
|
||||
doc["links"] = [[1, 1, 0, 2, 0, "X"], [2, 3, 0, 2, 0, "X"], [3, 9, 0, 2, 1, "X"]]
|
||||
reconcile(doc)
|
||||
assert [link_id(l) for l in doc["links"]] == [2], "newest link on a slot wins, dangling dropped"
|
||||
assert doc["nodes"][1]["inputs"][0]["link"] == 2
|
||||
assert doc["nodes"][2]["outputs"][0]["links"] == [2]
|
||||
assert_consistent(doc)
|
||||
|
||||
|
||||
def test_remap_simultaneous_adds():
|
||||
base = doc_with(2)
|
||||
def add(target_slot):
|
||||
return {"nodes": [node(3)], "added": [3],
|
||||
"links": {"upsert": [[1, 3, 0, 2, target_slot, "X"]], "added": [1]},
|
||||
"set": {"last_node_id": 3, "last_link_id": 1}}
|
||||
server = base
|
||||
pa, changed_a = remap_conflicts(server, add(0)); server = apply_patch(server, pa)
|
||||
pb, changed_b = remap_conflicts(server, add(1)); server = apply_patch(server, pb)
|
||||
assert not changed_a and changed_b
|
||||
assert sorted(n["id"] for n in server["nodes"]) == [1, 2, 3, 4]
|
||||
assert sorted(link_id(l) for l in server["links"]) == [1, 2]
|
||||
assert_consistent(server)
|
||||
|
||||
|
||||
def test_remap_leaves_updates_of_existing_nodes_alone():
|
||||
doc = doc_with(3)
|
||||
update = copy.deepcopy(doc["nodes"][0]); update["pos"] = [1, 1]
|
||||
patch, changed = remap_conflicts(doc, {"nodes": [update]})
|
||||
assert not changed and patch["nodes"][0]["id"] == 1
|
||||
|
||||
|
||||
def test_concurrent_edits_converge():
|
||||
rnd = random.Random(1)
|
||||
for _ in range(100):
|
||||
server = doc_with(4, [(1, 1, 0, 2, 0, "X")])
|
||||
observer = copy.deepcopy(server)
|
||||
patches = []
|
||||
for _ in range(2): # two people, same base, same instant
|
||||
nid, lid = server["last_node_id"] + 1, server["last_link_id"] + 1
|
||||
target = rnd.choice([n["id"] for n in server["nodes"]])
|
||||
patches.append({"nodes": [node(nid)], "added": [nid],
|
||||
"links": {"upsert": [[lid, nid, 0, target, rnd.randint(0, 1), "X"]], "added": [lid]},
|
||||
"set": {"last_node_id": nid, "last_link_id": lid}})
|
||||
for p in patches:
|
||||
p2, _ = remap_conflicts(server, p)
|
||||
server = apply_patch(server, p2)
|
||||
observer = apply_patch(observer, p2)
|
||||
assert_consistent(server)
|
||||
assert len(server["nodes"]) == 6
|
||||
assert json.dumps(server, sort_keys=True) == json.dumps(observer, sort_keys=True)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("node"), reason="node is not installed")
|
||||
def test_javascript_mirror_matches(tmp_path):
|
||||
"""graphdoc.js must produce exactly the same documents as graphdoc.py."""
|
||||
rnd = random.Random(7)
|
||||
cases = []
|
||||
for _ in range(60):
|
||||
doc = doc_with(5, [(1, 1, 0, 2, 0, "X"), (2, 3, 0, 4, 1, "X")])
|
||||
patches, cur = [], doc
|
||||
for _ in range(5):
|
||||
ids = [n["id"] for n in cur["nodes"]]
|
||||
p = {}
|
||||
if rnd.random() < .5:
|
||||
n = copy.deepcopy(rnd.choice(cur["nodes"])); n["pos"] = [rnd.randint(0, 99), 0]; p["nodes"] = [n]
|
||||
if rnd.random() < .4 and len(ids) > 1:
|
||||
a, b = rnd.sample(ids, 2); lid = cur["last_link_id"] + 1
|
||||
p["links"] = {"upsert": [[lid, a, 0, b, rnd.randint(0, 1), "X"]], "added": [lid]}
|
||||
p["set"] = {"last_link_id": lid}
|
||||
if rnd.random() < .2 and len(ids) > 3:
|
||||
p["removed"] = [rnd.choice(ids)]
|
||||
patches.append(p)
|
||||
cur = apply_patch(cur, p)
|
||||
cases.append({"doc": doc, "patches": patches, "expected": cur})
|
||||
data = tmp_path / "cases.json"
|
||||
data.write_text(json.dumps(cases))
|
||||
module = (Path(ROOT) / "web" / "rigshare" / "graphdoc.js").as_uri()
|
||||
script = tmp_path / "check.mjs"
|
||||
script.write_text(
|
||||
f'import fs from "fs"; import {{ applyPatch }} from "{module}";\n'
|
||||
'const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); let bad = 0;\n'
|
||||
'for (const c of cases) { let cur = c.doc; for (const p of c.patches) cur = applyPatch(cur, p);\n'
|
||||
' if (JSON.stringify(cur) !== JSON.stringify(c.expected)) bad++; }\n'
|
||||
'console.log(bad); process.exit(bad ? 1 : 0);\n')
|
||||
result = subprocess.run(["node", str(script), str(data)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, f"{result.stdout} mismatching cases\n{result.stderr}"
|
||||
Reference in New Issue
Block a user