Roadmap · Testing & CI · P2 · L

Automate the canvas-engine checks

A build-it-yourself guide. graph-engine.js is the one part of ModuLearn with no net at all — it's only ever been poked by hand. This folds the manual CDP smoke checks (add / connect / serialize / drag / zoom) into a repeatable headless test on the Playwright harness you already built for the round-trip test.

Done when: a headless browser test adds a node, drags it, zooms the canvas, and connects two ports — each asserted through the engine's own state — and it runs green in CI alongside the round-trip test.
0 / 0 steps

The core problem: the canvas is pixels, not DOM

Every node, port and wire is drawn into a single <canvas id="graphcanvas">. Playwright can't get_by_text("Iris") a node — there's nothing in the DOM to select. So you drive the engine two ways:

  1. Real mouse events (page.mouse.move/down/up/wheel) at pixel coordinates — this is what actually exercises drag, zoom and port-connect in the engine.
  2. A tiny coordinate seam that converts a node/port's graph position into viewport pixels, so the test knows where to aim. The transform is screen = (graph + ds.offset) · ds.scale (see zoomToFit), plus the canvas's own getBoundingClientRect() origin.
Assert on engine state, not pixels. After a drag you don't diff screenshots — you read serialize() (which includes each node's pos) or ds.scale and check they changed. Robust, and independent of exact rendering.

Steps

  1. Extend the test seam with coordinate helpers
    modulearn/static/graph.js — next to the existing window.__ml_* seam, inside the IIFE

    You already expose __ml_serialize/__ml_rebuild/__ml_clear/__ml_ready. Add a few more so a test can add a node and locate node titles / ports in viewport pixels.

    // ---- canvas-engine test seam (headless e2e) ----
    window.__ml_add = (typeId) => { addNode(typeId); };
    window.__ml_nodeIds = () => graph._nodes.map((n) => String(n.id));
    window.__ml_ds = () => ({ scale: canvas.ds.scale,
                              offset: [canvas.ds.offset[0], canvas.ds.offset[1]] });
    
    // graph coords -> viewport (client) pixels, for driving page.mouse
    window.__ml_toClient = (gx, gy) => {
      const r = canvas.canvas.getBoundingClientRect();
      return { x: r.left + (gx + canvas.ds.offset[0]) * canvas.ds.scale,
               y: r.top  + (gy + canvas.ds.offset[1]) * canvas.ds.scale };
    };
    // a grabbable point on a node's title bar (title sits above node.pos)
    window.__ml_nodeTitleClient = (id) => {
      const n = graph.getNodeById(+id); if (!n) return null;
      return window.__ml_toClient(n.pos[0] + 20, n.pos[1] - LiteGraph.NODE_TITLE_HEIGHT / 2);
    };
    // the centre of a port nub: isInput=false for an output slot, true for an input
    window.__ml_portClient = (id, isInput, slot) => {
      const n = graph.getNodeById(+id); if (!n) return null;
      const p = new Float32Array(2); n.getConnectionPos(isInput, slot, p);
      return window.__ml_toClient(p[0], p[1]);
    };
    Why these are safe to ship. They only read engine state and reuse addNode — the same function the palette calls. No behavior changes for real users; they're inert unless a test calls them.
  2. Add a helpers module for the tests
    tests/test_canvas_engine_e2e.py — top of the file

    Reuse the live_server fixture and page from the round-trip test. A couple of small helpers keep the tests readable. Seed graphs with fixed positions via __ml_rebuild (not __ml_add, which jitters position) so ports land at known spots.

    import pytest
    
    pytestmark = pytest.mark.e2e
    
    # dataset -> model, NO link yet — the connect test will wire it.
    _TWO_NODES = {
        "nodes": [
            {"id": "1", "type": "dataset.iris", "params": {}, "pos": [120, 200]},
            {"id": "2", "type": "model.mlp",
             "params": {"hidden": [16], "dropout": 0.0}, "pos": [480, 200]},
        ],
        "links": [],
    }
    
    def _boot(page, live_server, graph=None):
        page.goto(live_server)
        page.wait_for_function("() => window.__ml_ready === true", timeout=10000)
        if graph is not None:
            page.evaluate("(g) => { window.__ml_clear(); window.__ml_rebuild(g); }", graph)
    
    def _node(page, node_id):
        nodes = page.evaluate("() => window.__ml_serialize().nodes")
        return next((n for n in nodes if n["id"] == node_id), None)
  3. Test: add a node (palette path)
    tests/test_canvas_engine_e2e.py

    __ml_add is exactly what a palette click calls, so this exercises the engine's node-creation path and confirms it lands in serialize().

    def test_add_node(live_server, page):
        _boot(page, live_server)
        page.evaluate("() => { window.__ml_clear(); window.__ml_add('dataset.iris'); }")
        ids = page.evaluate("() => window.__ml_nodeIds()")
        assert len(ids) == 1
        assert _node(page, ids[0])["type"] == "dataset.iris"
  4. Test: drag moves a node
    tests/test_canvas_engine_e2e.py

    Grab the title bar, drag it, and assert the node's pos in serialize() changed. steps= makes the engine see intermediate move events (a single jump can be missed).

    def test_drag_moves_node(live_server, page):
        _boot(page, live_server, {"nodes": [_TWO_NODES["nodes"][0]], "links": []})
        nid = page.evaluate("() => window.__ml_nodeIds()")[0]
        before = _node(page, nid)["pos"]
    
        start = page.evaluate("(id) => window.__ml_nodeTitleClient(id)", nid)
        page.mouse.move(start["x"], start["y"])
        page.mouse.down()
        page.mouse.move(start["x"] + 140, start["y"] + 90, steps=10)
        page.mouse.up()
    
        after = _node(page, nid)["pos"]
        assert after != before
  5. Test: wheel zooms the canvas
    tests/test_canvas_engine_e2e.py

    Move over the canvas, scroll, and assert ds.scale changed. Zoom is a pure engine behavior with no DOM footprint, so the __ml_ds() read is the only way to see it.

    def test_wheel_zooms(live_server, page):
        _boot(page, live_server, {"nodes": [_TWO_NODES["nodes"][0]], "links": []})
        before = page.evaluate("() => window.__ml_ds().scale")
    
        box = page.locator("#graphcanvas").bounding_box()
        page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
        page.mouse.wheel(0, -400)          # scroll up over the canvas = zoom in
    
        after = page.evaluate("() => window.__ml_ds().scale")
        assert after != before
    If the scale doesn't budge, the wheel handler may require the pointer to be over the canvas first (the mouse.move above ensures that), or your engine may clamp zoom — try a larger delta or scroll the other direction.
  6. Test: drag connects two ports
    tests/test_canvas_engine_e2e.py

    The real payoff: drag from dataset.iris's output nub (slot 0) to model.mlp's dataset input (slot 0) and assert a link appears in serialize().

    def test_drag_connects_ports(live_server, page):
        _boot(page, live_server, _TWO_NODES)
        assert page.evaluate("() => window.__ml_serialize().links.length") == 0
    
        out = page.evaluate("() => window.__ml_portClient('1', false, 0)")  # dataset output
        inp = page.evaluate("() => window.__ml_portClient('2', true, 0)")   # model 'dataset' input
        page.mouse.move(out["x"], out["y"])
        page.mouse.down()
        page.mouse.move(inp["x"], inp["y"], steps=12)   # smooth drag so the engine tracks it
        page.mouse.up()
    
        links = page.evaluate("() => window.__ml_serialize().links")
        assert len(links) == 1
        assert links[0]["from"] == "1" and links[0]["to"] == "2"
        assert links[0]["to_port"] == "dataset"
    This is the flakiest test — expect to tune it. The engine only forms a link if the mouse-up lands inside the input port's hit radius. __ml_portClient aims at the exact port centre, which should hit, but if it's intermittent: raise steps, add a tiny page.wait_for_timeout(50) before mouse-up, or nudge the end point by a pixel or two. If drag-connect stays unreliable in CI, fall back to asserting link fidelity through the round-trip test (which already covers serialize/rebuild of links) and keep drag-connect as a local-only check.
  7. Run it — locally, then confirm CI

    The Playwright browser + the e2e marker are already wired from the round-trip work, so there's nothing new in ci.yml.

    pytest tests/test_canvas_engine_e2e.py -q      # the four canvas tests
    pytest -q                                       # full suite, e2e included
    pytest -q -m "not e2e"                           # fast local loop, browser skipped
    Keep e2e opt-outable. Because these are marked e2e, a contributor without a browser can still run -m "not e2e". CI installs chromium and runs the full set.
  8. Commit + mark shipped
    git add modulearn/static/graph.js tests/test_canvas_engine_e2e.py
    git commit -m "Automate canvas-engine checks: add/drag/zoom/connect in headless Chromium"
    git push
    gh run list --branch main --limit 1   # expect: success

    Then check the {area:"testing", … title:"Automate the canvas-engine checks"} card in docs/roadmap.html (shipped state is per-browser). That closes the last Testing/CI card.

Worth knowing

This is the highest-leverage net you'll add. The canvas engine is hand-written and, until now, entirely untested — the exact place a refactor could silently break wiring. Even this handful of interactions guards the core promise (drop, wire, serialize) end to end in a real browser. Screenshot/visual-regression testing is a possible follow-up, but asserting engine state is sturdier and won't break on a color tweak.