t2-mapper/src/torqueScript/runtime.spec.ts
2025-11-30 11:51:27 -08:00

1195 lines
34 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { readFileSync } from "node:fs";
import { createRuntime } from "./runtime";
import type { TorqueRuntimeOptions } from "./types";
import { parse, transpile } from "./index";
function run(script: string, options?: TorqueRuntimeOptions) {
const { $, $f, $g, state } = createRuntime(options);
const { code } = transpile(script);
const fn = new Function("$", "$f", "$g", code);
fn($, $f, $g);
return { $, $f, $g, state };
}
describe("TorqueScript Runtime", () => {
describe("global variables", () => {
it("sets and retrieves global variables", () => {
const { $g } = run(`
$myVar = 42;
$anotherVar = "hello";
`);
expect($g.get("myVar")).toBe(42);
expect($g.get("anotherVar")).toBe("hello");
});
it("handles array-style global variables", () => {
const { $g } = run(`
$items[0] = "first";
$items[1] = "second";
$items["key"] = "named";
`);
expect($g.get("items0")).toBe("first");
expect($g.get("items1")).toBe("second");
expect($g.get("itemskey")).toBe("named");
});
it("is case-insensitive", () => {
const { $g } = run(`
$MyVariable = 100;
`);
expect($g.get("myvariable")).toBe(100);
expect($g.get("MYVARIABLE")).toBe(100);
expect($g.get("MyVariable")).toBe(100);
});
});
describe("functions", () => {
it("defines and calls functions", () => {
const { $g } = run(`
function add(%a, %b) {
return %a + %b;
}
$result = add(10, 20);
`);
expect($g.get("result")).toBe(30);
});
it("handles local variables", () => {
const { $g } = run(`
function test() {
%x = 5;
%y = 10;
return %x * %y;
}
$result = test();
`);
expect($g.get("result")).toBe(50);
});
it("handles recursive functions", () => {
const { $g } = run(`
function factorial(%n) {
if (%n <= 1)
return 1;
return %n * factorial(%n - 1);
}
$result = factorial(5);
`);
expect($g.get("result")).toBe(120);
});
});
describe("string operations", () => {
it("concatenates with @", () => {
const { $g } = run(`
$result = "Hello" @ "World";
`);
expect($g.get("result")).toBe("HelloWorld");
});
it("concatenates with SPC", () => {
const { $g } = run(`
$result = "Hello" SPC "World";
`);
expect($g.get("result")).toBe("Hello World");
});
it("concatenates with TAB", () => {
const { $g } = run(`
$result = "Hello" TAB "World";
`);
expect($g.get("result")).toBe("Hello\tWorld");
});
it("concatenates with NL", () => {
const { $g } = run(`
$result = "Hello" NL "World";
`);
expect($g.get("result")).toBe("Hello\nWorld");
});
it("handles string re-assignment with concatenation", () => {
const { $g } = run(`
$str = "Hello";
$str = $str @ "World";
`);
expect($g.get("str")).toBe("HelloWorld");
});
it("compares strings case-insensitively with $=", () => {
const { $g } = run(`
$same = "Hello" $= "hello";
$diff = "Hello" $= "World";
`);
expect($g.get("same")).toBe(true);
expect($g.get("diff")).toBe(false);
});
});
describe("control flow", () => {
it("handles if/else", () => {
const { $g } = run(`
function checkValue(%x) {
if (%x > 10)
return "big";
else
return "small";
}
$a = checkValue(15);
$b = checkValue(5);
`);
expect($g.get("a")).toBe("big");
expect($g.get("b")).toBe("small");
});
it("handles for loops", () => {
const { $g } = run(`
function sumRange(%n) {
%sum = 0;
for (%i = 1; %i <= %n; %i++) {
%sum += %i;
}
return %sum;
}
$result = sumRange(5);
`);
expect($g.get("result")).toBe(15);
});
it("handles while loops", () => {
const { $g } = run(`
function countTo(%n) {
%count = 0;
%i = 0;
while (%i < %n) {
%count++;
%i++;
}
return %count;
}
$result = countTo(3);
`);
expect($g.get("result")).toBe(3);
});
it("handles switch statements", () => {
const { $g } = run(`
function getDay(%n) {
switch (%n) {
case 1:
return "Monday";
case 2:
return "Tuesday";
default:
return "Unknown";
}
}
$day1 = getDay(1);
$day2 = getDay(2);
$dayX = getDay(99);
`);
expect($g.get("day1")).toBe("Monday");
expect($g.get("day2")).toBe("Tuesday");
expect($g.get("dayX")).toBe("Unknown");
});
it("handles ternary operator", () => {
const { $g } = run(`
function check(%x) {
return %x > 5 ? "big" : "small";
}
$result = check(10);
`);
expect($g.get("result")).toBe("big");
});
});
describe("objects", () => {
it("creates objects with new", () => {
const { $ } = run(`
new ScriptObject(MyObject) {
value = 42;
name = "test";
};
`);
const obj = $.deref("MyObject");
expect(obj).toBeDefined();
expect($.prop(obj, "value")).toBe(42);
expect($.prop(obj, "name")).toBe("test");
});
it("creates object hierarchies", () => {
const { $ } = run(`
new SimGroup(Parent) {
new ScriptObject(Child1) { id = 1; };
new ScriptObject(Child2) { id = 2; };
};
`);
const parent = $.deref("Parent");
const child1 = $.deref("Child1");
const child2 = $.deref("Child2");
expect(parent).toBeDefined();
expect(child1).toBeDefined();
expect(child2).toBeDefined();
// Verify children are tracked
expect(parent._children).toContain(child1);
expect(parent._children).toContain(child2);
});
it("calls methods on objects", () => {
const { $g } = run(`
function ScriptObject::getValue(%this) {
return %this.value;
}
new ScriptObject(TestObj) { value = 42; };
$result = TestObj.getValue();
`);
expect($g.get("result")).toBe(42);
});
});
describe("datablocks", () => {
it("creates datablocks", () => {
const { $ } = run(`
datablock ItemData(Weapon) {
damage = 10;
range = 50;
};
`);
const db = $.deref("Weapon");
expect(db).toBeDefined();
expect($.prop(db, "damage")).toBe(10);
expect($.prop(db, "range")).toBe(50);
});
it("assigns datablock IDs starting at 3", () => {
const { $ } = run(`
datablock ItemData(FirstDatablock) {};
datablock ItemData(SecondDatablock) {};
`);
const first = $.deref("FirstDatablock");
const second = $.deref("SecondDatablock");
expect(first._id).toBe(3);
expect(second._id).toBe(4);
});
});
describe("bitwise operations", () => {
it("handles bitwise AND", () => {
const { $g } = run(`$result = 0xFF & 0x0F;`);
expect($g.get("result")).toBe(15);
});
it("handles bitwise OR", () => {
const { $g } = run(`$result = 0xF0 | 0x0F;`);
expect($g.get("result")).toBe(255);
});
it("handles bitwise XOR", () => {
const { $g } = run(`$result = 0xFF ^ 0x0F;`);
expect($g.get("result")).toBe(240);
});
it("handles left shift", () => {
const { $g } = run(`$result = 1 << 4;`);
expect($g.get("result")).toBe(16);
});
it("handles right shift", () => {
const { $g } = run(`$result = 16 >> 2;`);
expect($g.get("result")).toBe(4);
});
});
describe("numeric coercion", () => {
it("treats undefined variables as 0 in arithmetic", () => {
const { $g } = run(`$result = $undefined + 5;`);
expect($g.get("result")).toBe(5);
});
it("increments undefined variable from 0", () => {
const { $g } = run(`$x++;`);
expect($g.get("x")).toBe(1);
});
it("handles $x = $x + 1 on undefined variable", () => {
const { $g } = run(`$x = $x + 1;`);
expect($g.get("x")).toBe(1);
});
it("coerces string numbers in arithmetic", () => {
const { $g } = run(`
$a = "5";
$b = "3";
$result = $a + $b;
`);
expect($g.get("result")).toBe(8);
});
it("coerces empty string to 0", () => {
const { $g } = run(`
$a = "";
$result = $a + 10;
`);
expect($g.get("result")).toBe(10);
});
it("handles division by zero (returns 0)", () => {
const { $g } = run(`$result = 10 / 0;`);
expect($g.get("result")).toBe(0);
});
it("handles unary negation on strings", () => {
const { $g } = run(`
$a = "5";
$result = -$a;
`);
expect($g.get("result")).toBe(-5);
});
it("compares strings numerically with ==", () => {
const { $g } = run(`
$a = "10";
$b = 10;
$result = $a == $b;
`);
expect($g.get("result")).toBe(true);
});
it("compares strings numerically with <", () => {
const { $g } = run(`
$a = "5";
$b = "10";
$result = $a < $b;
`);
// Numeric comparison: 5 < 10 = true
// (String comparison would be false: "5" > "10")
expect($g.get("result")).toBe(true);
});
it("handles compound assignment with coercion", () => {
const { $g } = run(`
$x = "5";
$x += "3";
`);
expect($g.get("x")).toBe(8);
});
it("treats invalid strings as 0", () => {
const { $g } = run(`
$a = "hello";
$result = $a + 5;
`);
// "hello" -> NaN -> 0
expect($g.get("result")).toBe(5);
});
});
describe("packages", () => {
it("overrides functions when package is defined", () => {
const { $g } = run(`
function getMessage() {
return "original";
}
$before = getMessage();
package Override {
function getMessage() {
return "overridden";
}
};
$after = getMessage();
`);
expect($g.get("before")).toBe("original");
expect($g.get("after")).toBe("overridden");
});
it("calls parent function with Parent::", () => {
const { $g } = run(`
function getValue() {
return "base";
}
package Extended {
function getValue() {
return "extended(" @ Parent::getValue() @ ")";
}
};
$result = getValue();
`);
expect($g.get("result")).toBe("extended(base)");
});
});
describe("namespace method calls", () => {
it("calls methods via namespace syntax", () => {
const { $g } = run(`
function TestClass::init(%this, %name) {
%this.name = %name;
%this.value = 100;
}
function TestClass::getValue(%this) {
return %this.value;
}
function runTest() {
%obj = new ScriptObject() {};
TestClass::init(%obj, "test");
$name = %obj.name;
$value = TestClass::getValue(%obj);
}
runTest();
`);
expect($g.get("name")).toBe("test");
expect($g.get("value")).toBe(100);
});
});
describe("dynamic object IDs", () => {
it("assigns dynamic object IDs starting at 1027", () => {
const { $ } = run(`
new ScriptObject(FirstObject) {};
new ScriptObject(SecondObject) {};
`);
const first = $.deref("FirstObject");
const second = $.deref("SecondObject");
expect(first._id).toBe(1027);
expect(second._id).toBe(1028);
});
});
describe("built-in functions", () => {
it("strlen returns string length", () => {
const { $g } = run(`
$result = strlen("Hello");
`);
expect($g.get("result")).toBe(5);
});
it("strLen is case-insensitive", () => {
const { $g } = run(`
$result = STRLEN("test");
`);
expect($g.get("result")).toBe(4);
});
it("getWord extracts words by index", () => {
const { $g } = run(`
$first = getWord("one two three", 0);
$second = getWord("one two three", 1);
$third = getWord("one two three", 2);
`);
expect($g.get("first")).toBe("one");
expect($g.get("second")).toBe("two");
expect($g.get("third")).toBe("three");
});
it("getWordCount counts words", () => {
const { $g } = run(`
$result = getWordCount("one two three four");
`);
expect($g.get("result")).toBe(4);
});
it("strUpr converts to uppercase", () => {
const { $g } = run(`
$result = strUpr("hello");
`);
expect($g.get("result")).toBe("HELLO");
});
it("strLwr converts to lowercase", () => {
const { $g } = run(`
$result = strLwr("HELLO");
`);
expect($g.get("result")).toBe("hello");
});
it("getSubStr extracts substring", () => {
const { $g } = run(`
$result = getSubStr("Hello World", 0, 5);
`);
expect($g.get("result")).toBe("Hello");
});
it("strStr finds substring position", () => {
const { $g } = run(`
$found = strStr("Hello World", "World");
$notFound = strStr("Hello World", "xyz");
`);
expect($g.get("found")).toBe(6);
expect($g.get("notFound")).toBe(-1);
});
it("mFloor floors numbers", () => {
const { $g } = run(`
$result = mFloor(3.7);
`);
expect($g.get("result")).toBe(3);
});
it("mCeil ceils numbers", () => {
const { $g } = run(`
$result = mCeil(3.2);
`);
expect($g.get("result")).toBe(4);
});
it("mAbs returns absolute value", () => {
const { $g } = run(`
$pos = mAbs(5);
$neg = mAbs(-5);
`);
expect($g.get("pos")).toBe(5);
expect($g.get("neg")).toBe(5);
});
it("mSqrt returns square root", () => {
const { $g } = run(`
$result = mSqrt(16);
`);
expect($g.get("result")).toBe(4);
});
it("mPow raises to power", () => {
const { $g } = run(`
$result = mPow(2, 8);
`);
expect($g.get("result")).toBe(256);
});
it("getRandom returns number in range", () => {
const { $g } = run(`
$result = getRandom(1, 10);
`);
const result = $g.get("result");
expect(result).toBeGreaterThanOrEqual(1);
expect(result).toBeLessThanOrEqual(10);
});
});
describe("vector math", () => {
it("vectorAdd adds vectors", () => {
const { $g } = run(`
$result = vectorAdd("1 2 3", "4 5 6");
`);
expect($g.get("result")).toBe("5 7 9");
});
it("vectorSub subtracts vectors", () => {
const { $g } = run(`
$result = vectorSub("5 7 9", "4 5 6");
`);
expect($g.get("result")).toBe("1 2 3");
});
it("vectorScale scales a vector", () => {
const { $g } = run(`
$result = vectorScale("1 2 3", 2);
`);
expect($g.get("result")).toBe("2 4 6");
});
it("vectorDot computes dot product", () => {
const { $g } = run(`
$result = vectorDot("1 2 3", "4 5 6");
`);
// 1*4 + 2*5 + 3*6 = 4 + 10 + 18 = 32
expect($g.get("result")).toBe(32);
});
it("vectorCross computes cross product", () => {
const { $g } = run(`
$result = vectorCross("1 0 0", "0 1 0");
`);
expect($g.get("result")).toBe("0 0 1");
});
it("vectorLen computes vector length", () => {
const { $g } = run(`
$result = vectorLen("3 4 0");
`);
expect($g.get("result")).toBe(5);
});
it("vectorNormalize normalizes a vector", () => {
const { $g } = run(`
$result = vectorNormalize("3 0 0");
`);
expect($g.get("result")).toBe("1 0 0");
});
it("vectorDist computes distance between points", () => {
const { $g } = run(`
$result = vectorDist("0 0 0", "3 4 0");
`);
expect($g.get("result")).toBe(5);
});
it("getWord extracts vector components", () => {
const { $g } = run(`
$x = getWord("10 20 30", 0);
$y = getWord("10 20 30", 1);
$z = getWord("10 20 30", 2);
`);
expect($g.get("x")).toBe("10");
expect($g.get("y")).toBe("20");
expect($g.get("z")).toBe("30");
});
});
describe("runtime API (direct usage)", () => {
it("registerFunction and call via $f", () => {
const { $, $f } = createRuntime();
$.registerFunction("myFunc", (arg: number) => arg * 2);
expect($f.call("myfunc", 21)).toBe(42);
expect($f.call("MYFUNC", 10)).toBe(20);
});
it("registerMethod and call via $.call", () => {
const { $ } = createRuntime();
$.registerMethod("Player", "damage", (this_: any, amount: number) => {
return $.prop(this_, "health") - amount;
});
const player = $.create("Player", "TestPlayer", { health: 100 });
expect($.call(player, "damage", 25)).toBe(75);
});
it("$.concat joins strings", () => {
const { $ } = createRuntime();
expect($.concat("Hello", " ", "World")).toBe("Hello World");
expect($.concat("a", "b", "c")).toBe("abc");
});
it("$.streq compares strings case-insensitively", () => {
const { $ } = createRuntime();
expect($.streq("Hello", "HELLO")).toBe(true);
expect($.streq("Hello", "World")).toBe(false);
});
it("$.mod performs integer modulo", () => {
const { $ } = createRuntime();
expect($.mod(10, 3)).toBe(1);
expect($.mod(15, 4)).toBe(3);
});
it("$.prop and $.setProp handle case-insensitive properties", () => {
const { $ } = createRuntime();
const obj = $.create("Item", null, { MaxAmmo: 50 });
expect($.prop(obj, "maxammo")).toBe(50);
expect($.prop(obj, "MAXAMMO")).toBe(50);
$.setProp(obj, "maxammo", 100);
expect($.prop(obj, "MaxAmmo")).toBe(100);
});
it("$.datablock creates datablocks with inheritance", () => {
const { $ } = createRuntime();
$.datablock("ItemData", "BaseWeapon", null, { damage: 10, range: 100 });
$.datablock("ItemData", "Rifle", "BaseWeapon", { damage: 25 });
const base = $.deref("BaseWeapon");
const rifle = $.deref("Rifle");
expect($.prop(base, "damage")).toBe(10);
expect($.prop(base, "range")).toBe(100);
expect($.prop(rifle, "damage")).toBe(25);
expect($.prop(rifle, "range")).toBe(100); // inherited
});
it("$.package activates and overrides functions", () => {
const { $, $f } = createRuntime();
$.registerFunction("getMessage", () => "original");
expect($f.call("getMessage")).toBe("original");
$.package("TestPackage", () => {
$.registerFunction("getMessage", () => "overridden");
});
expect($f.call("getMessage")).toBe("overridden");
});
it("state tracks registered items", () => {
const { $, $g, state } = createRuntime();
$.registerFunction("func1", () => {});
$.registerFunction("func2", () => {});
$g.set("var1", 1);
$g.set("var2", 2);
$.create("Item", "item1", {});
expect(state.functions.size).toBe(2);
expect(state.globals.size).toBe(2);
expect(state.objectsByName.size).toBe(1);
});
});
describe("real TorqueScript files", () => {
const emptyAST = parse("");
async function runFile(filepath: string) {
const source = readFileSync(filepath, "utf8");
// Provide a loader that returns empty scripts for known dependencies
const runtime = createRuntime({
loadScript: async (path) => {
// Return empty script for known dependencies
if (path.toLowerCase().includes("aitdm")) return "";
return null;
},
});
const script = await runtime.loadFromSource(source);
script.execute();
return {
$: runtime.$,
$f: runtime.$f,
$g: runtime.$g,
state: runtime.state,
};
}
it("transpiles and executes TR2Roles.cs", async () => {
const { $g, state } = await runFile(
"docs/base/@vl2/TR2final105-server.vl2/scripts/TR2Roles.cs",
);
// Verify globals were set
expect($g.get("TR2::numRoles")).toBe(3);
expect($g.get("TR2::role0")).toBe("Goalie");
expect($g.get("TR2::role1")).toBe("Defense");
expect($g.get("TR2::role2")).toBe("Offense");
// Verify methods were registered
expect(state.methods.has("TR2Game")).toBe(true);
});
it("transpiles and executes a .mis mission file", async () => {
const { $, state } = await runFile(
"docs/base/@vl2/4thGradeDropout.vl2/missions/4thGradeDropout.mis",
);
// Verify MissionGroup was created
const missionGroup = $.deref("MissionGroup");
expect(missionGroup).toBeDefined();
expect(missionGroup._className).toBe("SimGroup");
// Verify child objects were created
const terrain = $.deref("Terrain");
expect(terrain).toBeDefined();
const sky = $.deref("Sky");
expect(sky).toBeDefined();
// Verify object count
expect(state.objectsByName.size).toBeGreaterThan(10);
});
it("transpiles TDMGame.cs with methods and parent calls", async () => {
const source = readFileSync(
"docs/base/@vl2/z_DMP2-V0.6.vl2/scripts/TDMGame.cs",
"utf-8",
);
const runtime = createRuntime({
loadScript: async (path) => {
if (path.toLowerCase().includes("aitdm")) return "";
return null;
},
});
const script = await runtime.loadFromSource(source);
script.execute();
// Verify methods were registered on TDMGame
expect(runtime.state.methods.has("TDMGame")).toBe(true);
const tdmMethods = runtime.state.methods.get("TDMGame");
expect(tdmMethods).toBeDefined();
// Verify transpiled code contains parent calls
const { code } = transpile(source);
expect(code).toContain("$.parent(");
});
});
describe("exec() with loadScript", () => {
function createLoader(
files: Record<string, string>,
): (path: string) => Promise<string | null> {
return async (path: string) => files[path.toLowerCase()] ?? null;
}
it("executes scripts via exec()", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/utils.cs": "$UtilsLoaded = true;",
}),
});
const script = await runtime.loadFromSource('exec("scripts/utils.cs");');
script.execute();
expect(runtime.$g.get("UtilsLoaded")).toBe(true);
});
it("handles chained exec() calls", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/a.cs": 'exec("scripts/b.cs"); $ALoaded = true;',
"scripts/b.cs": 'exec("scripts/c.cs"); $BLoaded = true;',
"scripts/c.cs": "$CLoaded = true;",
}),
});
const script = await runtime.loadFromSource('exec("scripts/a.cs");');
script.execute();
expect(runtime.$g.get("ALoaded")).toBe(true);
expect(runtime.$g.get("BLoaded")).toBe(true);
expect(runtime.$g.get("CLoaded")).toBe(true);
});
it("only executes each script once", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/counter.cs": "$LoadCount = $LoadCount + 1;",
}),
});
const script = await runtime.loadFromSource(`
exec("scripts/counter.cs");
exec("scripts/counter.cs");
exec("scripts/counter.cs");
`);
script.execute();
// Script should only be executed once
expect(runtime.$g.get("LoadCount")).toBe(1);
});
it("normalizes backslashes to forward slashes", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/utils.cs": "$Loaded = true;",
}),
});
const script = await runtime.loadFromSource(
'exec("scripts\\\\utils.cs");',
);
script.execute();
expect(runtime.$g.get("Loaded")).toBe(true);
});
it("returns false when script is not found", async () => {
const runtime = createRuntime({
loadScript: async () => null,
});
// Loading should warn but not throw
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const script = await runtime.loadFromSource(
'$Result = exec("scripts/missing.cs");',
);
warnSpy.mockRestore();
// Execution should warn and set $Result to false
const warnSpy2 = vi.spyOn(console, "warn").mockImplementation(() => {});
script.execute();
expect(warnSpy2).toHaveBeenCalledWith(
'exec("scripts/missing.cs"): script not found',
);
warnSpy2.mockRestore();
expect(runtime.$g.get("Result")).toBe(false);
});
it("returns false when exec called without loadScript", async () => {
const runtime = createRuntime();
// Warn about missing loader during load
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const script = await runtime.loadFromSource(
'$Result = exec("scripts/test.cs");',
);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
// Execution should warn and set $Result to false
const warnSpy2 = vi.spyOn(console, "warn").mockImplementation(() => {});
script.execute();
expect(warnSpy2).toHaveBeenCalledWith(
'exec("scripts/test.cs"): script not found',
);
warnSpy2.mockRestore();
expect(runtime.$g.get("Result")).toBe(false);
});
it("shares globals between exec'd scripts", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/setter.cs": "$SharedValue = 42;",
"scripts/getter.cs": "$ReadValue = $SharedValue;",
}),
});
const script = await runtime.loadFromSource(`
exec("scripts/setter.cs");
exec("scripts/getter.cs");
`);
script.execute();
expect(runtime.$g.get("SharedValue")).toBe(42);
expect(runtime.$g.get("ReadValue")).toBe(42);
});
it("shares functions between exec'd scripts", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/define.cs": 'function helper() { return "from helper"; }',
"scripts/use.cs": "$Result = helper();",
}),
});
const script = await runtime.loadFromSource(`
exec("scripts/define.cs");
exec("scripts/use.cs");
`);
script.execute();
expect(runtime.$g.get("Result")).toBe("from helper");
});
});
describe("loadFromSource", () => {
it("loads and executes a script from source", async () => {
const runtime = createRuntime();
const script = await runtime.loadFromSource("$Loaded = 1;");
script.execute();
expect(runtime.$g.get("Loaded")).toBe(1);
});
it("loads dependencies via loadScript option", async () => {
const runtime = createRuntime({
loadScript: async (path) => {
if (path.toLowerCase() === "scripts/dep.cs") {
return "$DepLoaded = 1;";
}
return null;
},
});
const script = await runtime.loadFromSource(
'exec("scripts/dep.cs"); $MainLoaded = 1;',
);
script.execute();
expect(runtime.$g.get("DepLoaded")).toBe(1);
expect(runtime.$g.get("MainLoaded")).toBe(1);
});
it("skips missing dependencies with warning", async () => {
const runtime = createRuntime({
loadScript: async () => null,
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await runtime.loadFromSource('exec("scripts/missing.cs");');
expect(warnSpy).toHaveBeenCalledWith(
"Script not found: scripts/missing.cs",
);
warnSpy.mockRestore();
});
});
describe("loadFromAST", () => {
it("loads and executes a script from AST", async () => {
const ast = parse("$FromAST = 42;");
const runtime = createRuntime();
const script = await runtime.loadFromAST(ast);
script.execute();
expect(runtime.$g.get("FromAST")).toBe(42);
});
it("loads dependencies via loadScript option", async () => {
const runtime = createRuntime({
loadScript: async (path) => {
if (path.toLowerCase() === "scripts/dep.cs") {
return "$DepValue = 100;";
}
return null;
},
});
const ast = parse('exec("scripts/dep.cs"); $MainValue = $DepValue + 1;');
const script = await runtime.loadFromAST(ast);
script.execute();
expect(runtime.$g.get("DepValue")).toBe(100);
expect(runtime.$g.get("MainValue")).toBe(101);
});
});
describe("loadFromPath", () => {
it("loads a script by path using loadScript option", async () => {
const runtime = createRuntime({
loadScript: async (path) => {
if (path.toLowerCase() === "scripts/test.cs") {
return "$PathLoaded = 1;";
}
return null;
},
});
const script = await runtime.loadFromPath("scripts/test.cs");
script.execute();
expect(runtime.$g.get("PathLoaded")).toBe(1);
});
it("recursively loads dependencies", async () => {
const sources: Record<string, string> = {
"scripts/main.cs": 'exec("scripts/a.cs"); $Main = 1;',
"scripts/a.cs": 'exec("scripts/b.cs"); $A = 1;',
"scripts/b.cs": "$B = 1;",
};
const runtime = createRuntime({
loadScript: async (path) => sources[path.toLowerCase()] ?? null,
});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
expect(runtime.$g.get("Main")).toBe(1);
expect(runtime.$g.get("A")).toBe(1);
expect(runtime.$g.get("B")).toBe(1);
});
it("throws when loadScript not provided", async () => {
const runtime = createRuntime();
await expect(runtime.loadFromPath("scripts/test.cs")).rejects.toThrow(
"loadFromPath requires loadScript option to be set",
);
});
it("throws when script not found", async () => {
const runtime = createRuntime({
loadScript: async () => null,
});
await expect(runtime.loadFromPath("scripts/missing.cs")).rejects.toThrow(
"Script not found: scripts/missing.cs",
);
});
it("handles circular dependencies", async () => {
const sources: Record<string, string> = {
"scripts/a.cs": 'exec("scripts/b.cs"); $A = 1;',
"scripts/b.cs": 'exec("scripts/a.cs"); $B = 1;',
};
const runtime = createRuntime({
loadScript: async (path) => sources[path.toLowerCase()] ?? null,
});
// Should not hang or throw
const script = await runtime.loadFromPath("scripts/a.cs");
script.execute();
expect(runtime.$g.get("A")).toBe(1);
expect(runtime.$g.get("B")).toBe(1);
});
it("does not reload already loaded scripts", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async (path) => {
if (path.toLowerCase() === "scripts/test.cs") {
loadCount++;
return "$Value = $Value + 1;";
}
return null;
},
});
runtime.$g.set("Value", 0);
await runtime.loadFromPath("scripts/test.cs");
await runtime.loadFromPath("scripts/test.cs");
expect(loadCount).toBe(1); // Only loaded once
});
});
describe("path option", () => {
it("loadFromSource with path prevents re-execution via exec()", async () => {
const runtime = createRuntime({
loadScript: async () => null,
});
const script = await runtime.loadFromSource("$Value = $Value + 1;", {
path: "scripts/main.cs",
});
runtime.$g.set("Value", 0);
script.execute();
expect(runtime.$g.get("Value")).toBe(1);
// Now try to exec the same path - should be a no-op
const script2 = await runtime.loadFromSource(
'exec("scripts/main.cs"); $AfterExec = $Value;',
);
script2.execute();
// Value should still be 1, not 2
expect(runtime.$g.get("Value")).toBe(1);
expect(runtime.$g.get("AfterExec")).toBe(1);
});
it("loadFromAST with path prevents re-execution via exec()", async () => {
const runtime = createRuntime({
loadScript: async () => null,
});
const ast = parse("$Value = $Value + 1;");
const script = await runtime.loadFromAST(ast, {
path: "scripts/main.cs",
});
runtime.$g.set("Value", 0);
script.execute();
expect(runtime.$g.get("Value")).toBe(1);
// Now try to exec the same path - should be a no-op
const script2 = await runtime.loadFromSource(
'exec("scripts/main.cs"); $AfterExec = $Value;',
);
script2.execute();
// Value should still be 1, not 2
expect(runtime.$g.get("Value")).toBe(1);
expect(runtime.$g.get("AfterExec")).toBe(1);
});
it("path is normalized for comparison", async () => {
const runtime = createRuntime({
loadScript: async () => null,
});
const script = await runtime.loadFromSource("$Value = $Value + 1;", {
path: "Scripts\\Main.cs",
});
runtime.$g.set("Value", 0);
script.execute();
// exec with different casing/slashes should still match
const script2 = await runtime.loadFromSource(
'exec("scripts/main.cs"); $AfterExec = $Value;',
);
script2.execute();
expect(runtime.$g.get("Value")).toBe(1);
});
});
});