npm test lints (eslint), builds the bundle (via pretest:js), runs the JS suite (node --test)
and then the Python tooling tests (unittest discover over test/test_*.py, so a new Python test
file is picked up without editing package.json). CI runs the same on every push/PR to main
(.github/workflows/test.yml).
The suite is layered by how strong a guarantee each layer can give.
test/properties.test.js checks invariants over hundreds of seeded-random inputs (a counterexample
prints the seed and reproduces deterministically):
- export/import is lossless (vertices, outline, label, and the editable bezier);
- the bezier fit is independent of where the boundary ring starts;
- a sampled bezier never escapes the bbox of its control points (no blow-up);
- selection selects exactly the vertices a polygon encloses (graded against an independent rectangle oracle, not the impl's own point-in-polygon);
- the boundary ring is always a subset of the selection;
- membership re-derived from a curve is deterministic — what you see is what you get;
- a homography composed with its inverse is the identity;
core/bezier.js's open-curve invariants:fitOpenBezierpins the traced endpoints, an open curve's endpoints stay corners (never smooth) through a fit and through any single edit (move/smooth-toggle/delete/split), andisClosedreportsfalsethroughout — a sulcus can never silently acquire a closing segment.
The example-based core tests (geom, selection, bezier, transform, shape-model,
uv-membership, timer-set) remain as targeted cases.
A recurring class of bug here has been two code paths restating the same rule and drifting. Three tests pin that the restatements are gone:
test/bezier-topology.test.js: the closed/open segment topology (how many segments, which anchors each joins, the 3/2 anchor floor) is defined once incore/bezier.js(segCount/segControls/minAnchors/hasCurve) and read by both samplers, both nearest-point searches,deleteAnchor, and the adapter's SVG path writer. The test checksevalBezier/nearestOnBezierdispatch to the explicit forms, that the adapter emits oneCper segment and aZonly when closed, and that every consumer honors the same floor.test/draw-pipeline.test.js(uvPxCorrespondences): the edit overlay and the sulcus trace pipeline fit their homographies from the same flattening ofprojectVerticesInUvBounds.test/shape-model.test.js(defaultName): the name a prompt offers and the name an unnamed import gets come from one rule.
test/dom-utils.test.js pins isTextEntry, the single "is the user typing?" predicate behind
every keyboard handler (controller Shift/Esc and the editor's Delete): a file input, button,
checkbox or slider holding focus must not swallow a gesture.
upstream/stage_into_pycortex.py generates the pycortex incorporation PR's diff.
test/test_upstream.py drives its pure patch functions against verbatim fixtures of the pycortex
anchor regions: the template block lands between the python_interface block and
{% block javascripts %}; the view.py kwarg/docstring/generate-arg insertions land in order and
only in make_static (both the docstring and the tpl.generate anchors appear twice in the real
file — the fixtures reproduce that); reruns are no-ops; a missing anchor exits loudly having
written nothing. It also pins that the built bundle stays free of the two patterns
cortex/webgl/htmlembed.py rewrites inside every embedded script (new Worker(,
attr('src', …)) — the reason the CSS rides inside the JS is the same module's non-nesting CSS
brace parser. The shipped upstream/test_webgl_roidraw.py needs an importable cortex, so it runs
in pycortex's CI, not here; its exact assertions were reproduced against the real patched template
with Tornado (pycortex's template engine) when the kit was built.
core/svg-export.js is the pure writer for the sulci layer of an overlays.svg.
test/svg-export.test.js pins the writer's decisions by string: a sulcus path never closes with a
trailing Z (the only on-disk marker separating a sulcus from an ROI); curves that share a name
merge into a single <g inkscape:label="…"> rather than colliding, exactly as pycortex's own
multi-hemisphere CaS does; names are XML-escaped; the labels layer exists and holds no <text>.
String tests were not enough, and had already shipped a broken file. Every assertion above
passes against a bare fragment that uses the inkscape: prefix with no namespace declaration —
which no XML parser will open. So test/test_sulci_svg.py generates the writer's real output with
node and parses it with Python's ElementTree, using cortex/svgoverlay.py's own namespaces and
findall queries. It asserts the document parses, that _find_layer locates the sulci layer and
both its sublayers, that same-named curves round-trip through the parser as one group of two paths,
that no d closes, and that the labels layer holds no <text> — because Labels.__init__ reads
float(text.get('x')) off every <text> it finds, so a label carrying only a vertex index makes
db.get_overlay(subject) raise TypeError. It needs neither pycortex nor a subject, so it runs in
CI. It is the closest thing to the round-trip below that can be automated here.
core/draw-mode.js (test/draw-mode.test.js) is the extracted pure state machine behind Draw mode.
Its job is the subtle bit: the flatten glide emits many non-flat frames on its way to flat, and
those must not bounce the user out of Draw — but a genuine inflate after reaching flat must. That
logic is now provable in isolation instead of only eyeballed in a browser.
draw-pipeline.js (test/draw-pipeline.test.js) is driven against test/fake-adapter.js (a
ViewerAdapter over a known grid). The fake projects uv→px through a real rotation+offset (not
px == uv·scale) and puts the two hemispheres in disjoint uv bands, so the px→uv round-trip and
hemisphere separation are actually exercised. Because the grid's geometry is analytic, the tests
assert real properties rather than tautologies — no browser, no pycortex:
- the ROI path (lasso → select → fit → re-derive) selects exactly the enclosed vertices, fits an editable bezier, and re-derives identical membership;
- the sulcus path (
curveFromTrace) round-trips a stroke px→uv through the fitted homography back onto the uv points it was drawn through, and returnsnullon a degenerate stroke or a collinear/unfittable view; labelForCurvepicks the vertex nearest the curve's midpoint, and picks a different vertex for a translated curve — the guard that a reshaped sulcus relabels instead of stranding itsdata-ptidxat the original midpoint.
All five edit ops (moveAnchor, moveHandle, setAnchorSmooth, splitSegment, deleteAnchor)
share one contract, pinned in test/bezier.test.js: an out-of-range anchor or segment index is a
no-op returning an unchanged copy. Nothing throws, nothing half-applies, and the four parallel
arrays keep the same length.
The edit overlay holds drag, hover, and selection targets across pointer events, so an anchor list
that shrinks under one (Delete pressed mid-drag) leaves a stale index behind. Writing through it
used to append past the end of the handle arrays, silently desynchronizing their lengths from
anchors. The ops used to disagree about this — two refused, two threw a TypeError, and
setAnchorSmooth on a closed curve grew smooth[] past anchors[] with holes in it — which meant
each caller had to know which op it was calling. The test now sweeps every op × every bad index.
The overlay separately drops its drag and hover targets whenever the anchor count changes, which is the only place that can catch the other half of that bug — a stale index that is still in range names a different anchor, and no pure function can tell.
ui/overlay-geom.js (test/overlay-geom.test.js) holds the grab-an-anchor / grab-a-handle math the
edit overlay delegates to, so the hit-testing is testable without a canvas.
test/adapter-contract.test.jsasserts both the realPycortexAdapterand the fake implement the wholeViewerAdaptercontract, so adding a contract method can't silently leave one adapter behind.preflightHost()(test/host-preflight.test.js) inspects the host for the core pycortex internals the adapter needs (THREE,mriview.get_position, the surface + pivots, and both hemispheres'positionanduvgeometry, svgoverlay) and, at attach time, throws a loud, specific error naming what's missing — so if pycortex drifts (renamedget_position, restructured surface), users get a clear message instead of silent wrongness.
test/bundle.test.js loads the freshly built dist/roidraw.bundle.js in a sandbox and asserts it
exposes window.ROIDraw.{attach,autoAttach,…} with its CSS inlined. A broken/half-built bundle —
exactly what a release would ship — fails here, not in someone's browser.
The one thing unit tests cannot prove is that PycortexAdapter talks correctly to a live
pycortex viewer — its correctness is defined by pycortex/THREE internals we don't control. Two things
mitigate this today:
preflightHost()converts integration breakage into a clear runtime error.- The public demo viewer (
gallantlab/viewer-stories-group-roidraw) is a real baked viewer that was manually browser-verified.
The strongest closer would be a headless-browser smoke test (Playwright): load a real baked
viewer, autoAttach, dispatch a synthetic lasso, click Export, assert non-empty JSON of the right
shape — run in CI against a pinned viewer fixture. That needs a checked-in viewer fixture + a browser
in CI, so it's deliberately not wired into npm test yet; it's the recommended next step for true
end-to-end coverage.
Sulcus drawing adds gaps of the same kind. Some were closed by a one-off browser check against the
live viewer-stories-group-roidraw viewer, driven over the Chrome DevTools Protocol (2026-07-09).
That check is not in CI — it needs a baked static viewer, which this repo does not carry.
Closed by that check, against a real pycortex viewer:
adapter/pycortex-adapter.js's open-curve rendering. With three sulci and one ROI in the model,setOverlayLayerproduced 8<path>elements (halo + stroke per shape): the 6 sulcal ones carry no trailingZ, the 2 ROI ones do.exportSulciMarkupon the live overlay: 3 paths, none closing; two same-namedCScurves merged into one<g inkscape:label="CS">with a<path>each; a hostile name XML-escaped; style exactlyfill:none;stroke:white;stroke-width:6;stroke-opacity:0.6;stroke-linecap:round. (That run predates the export rewrite: it saw the old bare fragment,<text data-ptidx>labels and all. Re-run it.)- No sulcus leaks into the
vertexset-v2JSON, and itsformatstring is unchanged.
Worth stating plainly, because it is easy to mistake the property tests for end-to-end coverage. The two export formats have different gaps.
sulci.svg has a foreign consumer. The whole point of matching pycortex's format is that
cortex/svgoverlay.py, quickflat, the WebGL viewer, and Inkscape read it.
- Covered:
test/test_sulci_svg.pyparses the real output with an XML parser and reproducessvgoverlay.py's layer/shape/label queries against it (see above). This closed three defects that the string-matching tests could not see: an undeclaredinkscape:namespace prefix (nothing would parse the file),<text data-ptidx>labels with nox/y(db.get_overlay()raisedTypeErroron them), and the absence of the mandatory-but-emptylabelslayer. - Still open:
svgoverlay.pyitself has never run on it. Merging the shape groups into a real subject'soverlays.svg, confirmingdb.get_overlay()exposes them undersvg.sulci, and rendering withquickflat.make_figure(…, with_sulci=True)needs a subject and an importablecortex— neither is available here.
rois.json has no foreign consumer at all. pycortex-roidraw/vertexset-v2 is a roidraw-native
format; no Python reader exists here or in pycortex, deliberately (get_roi_masks does not read
it). So "read it back" can only mean re-importing into roidraw. The format round-trip is
strong — test/properties.test.js runs toJSON → JSON.stringify → JSON.parse → loadJSON
over 300 seeded trials and compares vertices, outline, label, and bezier — and loadJSON's deep
copy is pinned in test/shape-model.test.js (an imported bezier is mutated in place by the edit
overlay; aliasing the parsed document would let an edit reach back into it). But the README's
"re-imports in any viewer on the same surface" has never been demonstrated across two viewers or
against real surface data.
Still open, in both directions:
- The browser
_importpath —FileReader, the empty-file guard,reader.onerror,backfillBezier/backfillLabelfor v1 files,_sync, the panel refresh — has zero coverage. No test touchesFileReaderor_import, and the live-viewer check calledtoJSONonly, neverloadJSON. - Both download paths (
Blob→ anchor →revokeObjectURL), for JSON and for SVG. The 4000 ms deferred teardown exists because Firefox otherwise writes a 0-byte file — and is untested. - The exported
sulci.svgfragment has never been round-tripped through pycortex'ssvgoverlay.pyparser (see above). index.jshas no unit harness. ThelabelForCurveregression guard (a reshaped sulcus must relabel) sits on the pure helper indraw-pipeline.js, not on_applyEdit's sulcus branch that calls it.- The
ui/overlays have no headless test. The interactive gestures — dragging a trace, the single handle at an open curve's endpoints, the absent closing chord in the edit preview — were never exercised programmatically; onlytest/bundle.test.jscatches build breakage.