PetriEditorExtensionWorkout.kt
src/jvmMain/kotlin/afield/examples/petri/PetriEditorExtensionWorkout.kt · 255 lines
package afield.examples.petri
import afield.simulation.petri.PetriCombinators
import afield.simulation.petri.PetriEditorExtension
import afield.media.image.util.NodeTool
import java.nio.file.Files
import kotlin.io.path.writeText
FIELD_TEST id: petri.editor-extension.workout runner: jvm-main entrypoint: afield.examples.petri.PetriEditorExtensionWorkout tags: [smoke, examples, petri, monaco, language-extension] timeoutSec: 30 expect: exitCode: 0 stdoutContains: - "petri editor extension workout passed"
object PetriEditorExtensionWorkout
Runs petri.js for real, under node, against a mock Monaco.
The point of a node probe rather than a unit test of a Kotlin mirror is that it exercises the
exact file that ships — PetriEditorExtension.javascript() reads the same resource the browser
is served. A highlighter that only works in a re-implementation is a highlighter that does not
work.
It also checks the one thing most likely to rot: the combinator vocabulary is written down twice,
once in PetriNotation.kt and once in petri.js, because the editor cannot call Kotlin. This
asserts the two lists are the same, so adding a combinator on one side and forgetting the other
fails here rather than silently underlining valid source in red.
fun main(args: Array<String>)
@JvmStatic
fun main(args: Array<String>) {
check(runTests()) { "petri editor extension workout failed" }
}
fun runTests(): Boolean
fun runTests(): Boolean {
val js = PetriEditorExtension.javascript()
require("FieldLanguageExtensions.register" in js)
require("id: extensionId" in js)
require("#petri" in js)
require("updateEmbeddedHighlight" in js)
require("updateDiagnostics" in js)
require("buildContextPayload" in js)
require("provideCompletionItems" in js)
require("registerPetriLanguage" in js)
require("setLanguageConfiguration" in js)
require("analyzeLexical" in js)
checkCombinatorsAgree(js)
runNodeProbe(js)
println("petri editor extension workout passed")
return true
}
The editor's combinator list must equal PetriCombinators's.
The editor is JavaScript in a browser and the registry is Kotlin on a server, so there is no way to share the list at run time — but there is every way to check it at build time, and an unchecked duplicate is just a bug with a delay on it.
source — 188 lines, from line 67
private fun checkCombinatorsAgree(js: String) {
val block = js.substringAfter("const combinatorWords = [", "").substringBefore("];", "")
require(block.isNotBlank()) { "could not find combinatorWords in petri.js" }
val inEditor = Regex("\"([^\"]+)\"").findAll(block).map { it.groupValues[1] }.toSet()
val inKotlin = PetriCombinators.names.toSet()
require(inEditor == inKotlin) {
"petri.js and PetriCombinators disagree: only in the editor ${inEditor - inKotlin}, " +
"only in Kotlin ${inKotlin - inEditor}"
}
}
private fun runNodeProbe(extensionSource: String) {
val dir = Files.createTempDirectory("petri-editor-extension-")
val extensionFile = dir.resolve("petri-extension.js")
val probeFile = dir.resolve("probe.js")
extensionFile.writeText(extensionSource)
probeFile.writeText(nodeProbeSource())
val node = NodeTool.resolveNode()
?: error("petri editor-extension workout needs node: ${NodeTool.unavailableMessage()}")
val process = ProcessBuilder(node, probeFile.toString(), extensionFile.toString())
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
val exit = process.waitFor()
require(exit == 0) { "node probe failed with exit $exit\n$output" }
require("petri node probe passed" in output) { output }
}
private fun nodeProbeSource(): String = """
const fs = require("fs");
const source = fs.readFileSync(process.argv[2], "utf8");
const registered = [];
class Range {
constructor(startLineNumber, startColumn, endLineNumber, endColumn) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
}
global.window = {
FieldLanguageExtensions: {
register(extension) {
registered.push(extension);
return extension;
}
},
globalMonaco: {
Range,
languages: { CompletionItemKind: { Function: 1, Keyword: 2, EnumMember: 3, Property: 4 } },
MarkerSeverity: { Warning: 4, Error: 8 },
editor: {
markerCalls: [],
setModelMarkers(model, owner, markers) {
this.markerCalls.push({ model, owner, markers });
}
}
}
};
global.setTimeout = (fn) => { fn(); return 0; };
eval(source);
if (registered.length !== 1) throw new Error("expected one registered extension, got " + registered.length);
const extension = registered[0];
if (extension.id !== "petri") throw new Error("wrong extension id " + extension.id);
const text = `
import afield.simulation.petri.*
val net = petri(${"\"\"\""}
#petri
!grid 1/16
kick : 1
verse : "bd ~ sd ~"
kick -[ hit @1/16 pri=2 : bd ]-> hats
~armed + 2*hats -[ roll @1/8 => stack ]-> fill
${"\"\"\""})
`.trim();
function positionAt(offset) {
const before = text.slice(0, offset).split("\n");
return { lineNumber: before.length, column: before[before.length - 1].length + 1 };
}
function offsetAt(position) {
const lines = text.split("\n");
let offset = 0;
for (let i = 0; i < position.lineNumber - 1; i++) offset += lines[i].length + 1;
return offset + position.column - 1;
}
const model = {
getLanguageId: () => "kotlin",
getValue: () => text,
getVersionId: () => 7,
getPositionAt: positionAt,
getOffsetAt: offsetAt,
};
const regions = extension.findEmbeddedRegions(model);
if (regions.length !== 1) throw new Error("expected one #petri region, got " + regions.length);
if (!regions[0].source.includes("-[ hit")) throw new Error("region source missing the arc line");
const collections = [];
const stackOffset = text.indexOf("stack") + 2;
const editor = {
getModel: () => model,
getPosition: () => positionAt(stackOffset),
hasTextFocus: () => true,
createDecorationsCollection(initial) {
const state = { latest: initial || [], set(value) { this.latest = value; } };
collections.push(state);
return state;
},
onDidChangeCursorPosition() {},
onDidChangeModelContent() {},
onDidChangeModel() {},
};
extension.install(editor, window.globalMonaco);
extension.updateEmbeddedHighlight(editor);
extension.updateDiagnostics(editor);
const highlight = editor._petriHighlightDecorations && editor._petriHighlightDecorations.latest;
if (!highlight || highlight.length < 20) {
throw new Error("expected highlight decorations, got " + (highlight && highlight.length));
}
// A transition name inside -[ ]- must be coloured differently from a place name outside it:
// that contrast is what makes the net's bipartite structure readable in plain text.
const tokenize = window.__petriExtensionProof.tokenize;
const tokens = tokenize(regions[0].source, 0);
const classes = new Set(highlight.map((d) => d.options.inlineClassName));
if (!classes.has("afield-stacksl-token-definition")) throw new Error("transition names are not distinguished");
if (!classes.has("afield-stacksl-token-identifier")) throw new Error("place names are not distinguished");
if (!classes.has("afield-stacksl-token-creative")) throw new Error("a quoted pattern is not distinguished");
// Valid source is clean. This matters more than it looks: a highlighter that cries wolf on
// correct code is worse than one that says nothing.
const analyzeLexical = window.__petriExtensionProof.analyzeLexical;
const clean = analyzeLexical(regions[0].source);
if (clean.length !== 0) throw new Error("valid petri flagged: " + JSON.stringify(clean));
const cases = [
["a -[ t @nonsense ]-> b", "number of cycles"],
["a -[ t => wobble ]-> b", "not a combinator"],
["!timing sideways", "!timing must be"],
["!grid 0", "positive number of cycles"],
["a -[ t pri=x ]-> b", "whole number"],
["verse : \"unterminated", "unterminated"],
["a -[ t ]-> ~b", "left of a transition"],
["a -[ t -> b", "no matching"],
["just some words", "expected a place declaration"],
];
for (const [src, needle] of cases) {
const found = analyzeLexical(src);
if (!found.some((issue) => issue.message.includes(needle))) {
throw new Error("expected '" + needle + "' for " + JSON.stringify(src) + ", got " + JSON.stringify(found));
}
}
// Diagnostics reach Monaco as markers, not just as a return value.
const markerCalls = window.globalMonaco.editor.markerCalls;
if (!markerCalls.some((call) => call.owner === "petri-diagnostics")) {
throw new Error("diagnostics never reached setModelMarkers");
}
// The context badge explains the word under the caret.
const payload = extension.buildContextPayload(editor, () => ({ uid: "box", property: "code" }), {});
if (!payload || !payload.html.includes("Petri") || !payload.html.includes("stack")) {
throw new Error("bad context payload " + JSON.stringify(payload));
}
if (!payload.html.includes("layered")) {
throw new Error("context payload missing the combinator's description " + JSON.stringify(payload));
}
// Completion is context-sensitive: after `=>` it offers combinators and nothing else.
const afterArrow = positionAt(text.indexOf("=> stack") + 3 + "sta".length);
const combinators = extension.provideCompletionItems(editor, model, afterArrow, { monaco: window.globalMonaco });
if (!combinators.suggestions.some((item) => item.label === "stack")) {
throw new Error("no combinator completions after => : " + JSON.stringify(combinators.suggestions.map((s) => s.label)));
}
if (combinators.suggestions.some((item) => item.label === "!grid")) {
throw new Error("directives should not be offered after =>");
}
console.log("petri node probe passed");
""".trimIndent()
}