t2-mapper/src/torqueScript/runtime.spec.ts

2988 lines
89 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
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);
// Provide $l (locals) at module scope for top-level local variable access
const $l = $.locals();
const fn = new Function("$", "$f", "$g", "$l", code);
fn($, $f, $g, $l);
return { $, $f, $g, $l, 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);
});
it("handles top-level local variables", () => {
const { $g, $l } = run(`
%x = 5;
%y = 10;
$result = %x * %y;
%name = "test";
`);
expect($g.get("result")).toBe(50);
expect($l.get("x")).toBe(5);
expect($l.get("y")).toBe(10);
expect($l.get("name")).toBe("test");
});
it("top-level locals are separate from function locals", () => {
const { $g } = run(`
%x = 100;
function getX() {
%x = 42;
return %x;
}
$funcResult = getX();
$topResult = %x;
`);
expect($g.get("funcResult")).toBe(42);
expect($g.get("topResult")).toBe(100);
});
});
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);
});
it("allows assignment on the right side of concat (SPC)", () => {
// Pattern from Training5.mis:
// echo("InitialBarrel =" SPC %initialBarrel = (%skill < 3 ? "Missile" : "Plasma"));
const { $g, $l } = run(`
$skill = 2;
$result = "Barrel:" SPC %barrel = ($skill < 3 ? "Missile" : "Plasma");
`);
// The assignment should happen AND the result should include the assigned value
expect($l.get("barrel")).toBe("Missile");
expect($g.get("result")).toBe("Barrel: Missile");
});
it("allows assignment on the right side of concat (@)", () => {
const { $g, $l } = run(`
$result = "value=" @ %x = 42;
`);
expect($l.get("x")).toBe(42);
expect($g.get("result")).toBe("value=42");
});
it("allows multiple concat with assignment at end", () => {
// Assignment is right-associative and consumes everything to its right
// So "a" SPC "b" SPC %x = "c" parses as "a" SPC "b" SPC (%x = "c")
const { $g, $l } = run(`
$result = "a" SPC "b" SPC %x = "c";
`);
expect($l.get("x")).toBe("c");
expect($g.get("result")).toBe("a b c");
});
});
describe("operator precedence", () => {
it("parses unary minus with correct precedence", () => {
// -1 + 2 should be (-1) + 2 = 1, NOT -(1 + 2) = -3
const { $g } = run(`
$result = -1 + 2;
`);
expect($g.get("result")).toBe(1);
});
it("parses unary minus in complex expressions", () => {
const { $g } = run(`
$a = -5 * 2; // (-5) * 2 = -10
$b = 10 + -3; // 10 + (-3) = 7
$c = -2 + -3; // (-2) + (-3) = -5
$d = -10 / 2 + 1; // ((-10) / 2) + 1 = -4
`);
expect($g.get("a")).toBe(-10);
expect($g.get("b")).toBe(7);
expect($g.get("c")).toBe(-5);
expect($g.get("d")).toBe(-4);
});
it("parses logical not with correct precedence", () => {
const { $g } = run(`
$a = !0 + 1; // (!0) + 1 = 1 + 1 = 2
$b = !1 || 1; // (!1) || 1 = 0 || 1 = 1
`);
expect($g.get("a")).toBe(2);
expect($g.get("b")).toBe(1);
});
it("parses bitwise not with correct precedence", () => {
// The runtime uses unsigned 32-bit for bitwise ops
// ~1 >>> 0 = 4294967294 (0xFFFFFFFE), then + 3 = 4294967297
// The key test here is precedence: (~1) + 3, not ~(1 + 3)
const { $g } = run(`
$a = ~1 + 3;
`);
// ~1 as unsigned 32-bit = 4294967294, plus 3 = 4294967297
expect($g.get("a")).toBe(4294967297);
});
it("handles multiple unary operators", () => {
const { $g } = run(`
$a = --5; // -(-5) = 5
$b = !!1; // !(!1) = !(0) = 1 (truthy)
$c = -(-(-1)); // 1 negated 3 times = -1
`);
expect($g.get("a")).toBe(5);
expect($g.get("b")).toBeTruthy(); // JavaScript returns boolean, TorqueScript treats as truthy
expect($g.get("c")).toBe(-1);
});
});
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 do-while loops", () => {
const { $g } = run(`
function countdown(%n) {
%result = "";
do {
%result = %result @ %n;
%n--;
} while (%n > 0);
return %result;
}
$result = countdown(3);
`);
expect($g.get("result")).toBe("321");
});
it("handles break in loops", () => {
const { $g } = run(`
%sum = 0;
for (%i = 0; %i < 10; %i++) {
if (%i == 5) break;
%sum += %i;
}
$result = %sum;
`);
expect($g.get("result")).toBe(10); // 0+1+2+3+4
});
it("handles continue in loops", () => {
const { $g } = run(`
%sum = 0;
for (%i = 0; %i < 5; %i++) {
if (%i == 2) continue;
%sum += %i;
}
$result = %sum;
`);
expect($g.get("result")).toBe(8); // 0+1+3+4
});
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 switch with 'or' for multiple cases", () => {
const { $g } = run(`
function isWeekend(%day) {
switch (%day) {
case 6 or 7:
return true;
default:
return false;
}
}
$sat = isWeekend(6);
$sun = isWeekend(7);
$mon = isWeekend(1);
`);
expect($g.get("sat")).toBe(true);
expect($g.get("sun")).toBe(true);
expect($g.get("mon")).toBe(false);
});
it("handles switch$ (case-insensitive string switch)", () => {
// Note: switch$ uses arrow functions internally, so return statements
// don't work directly. Use variable assignment instead.
const { $g } = run(`
function getColor(%name) {
%result = "";
switch$ (%name) {
case "red":
%result = "#FF0000";
case "GREEN":
%result = "#00FF00";
default:
%result = "#000000";
}
return %result;
}
$red = getColor("RED");
$green = getColor("green");
$other = getColor("blue");
`);
expect($g.get("red")).toBe("#FF0000");
expect($g.get("green")).toBe("#00FF00");
expect($g.get("other")).toBe("#000000");
});
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);
});
it("sets properties on objects referenced by name (bareword)", () => {
// This is the pattern used in mission files: Game.cdtrack = 2
const { $ } = run(`
new ScriptObject(Game) {
class = "DefaultGame";
};
Game.cdtrack = 2;
Game.musicTrack = "lush";
`);
const game = $.deref("Game");
expect(game).toBeDefined();
expect($.prop(game, "cdtrack")).toBe(2);
expect($.prop(game, "musicTrack")).toBe("lush");
});
it("gets properties from objects referenced by name (bareword)", () => {
const { $g } = run(`
new ScriptObject(Config) {
maxPlayers = 32;
serverName = "Test Server";
};
$players = Config.maxPlayers;
$name = Config.serverName;
`);
expect($g.get("players")).toBe(32);
expect($g.get("name")).toBe("Test Server");
});
it("handles property access on non-existent objects gracefully", () => {
const { $g } = run(`
$value = NonExistent.property;
NonExistent.property = 42;
`);
// Should return empty string for non-existent object
expect($g.get("value")).toBe("");
});
it("resolves objects by numeric ID", () => {
const { $, $g } = run(`
new ScriptObject(TestObj) {
value = 100;
};
`);
const obj = $.deref("TestObj");
const id = obj._id;
// Access via ID should work the same as by name
expect($.prop(id, "value")).toBe(100);
$.setProp(id, "modified", true);
expect($.prop(obj, "modified")).toBe(true);
});
it("handles direct indexed access on bareword objects", () => {
// In TorqueScript, obj[idx] sets a property directly on the object
const { $, $g } = run(`
new ScriptObject(Data) {};
Data[0] = "first";
Data[1] = "second";
$first = Data[0];
$second = Data[1];
`);
expect($g.get("first")).toBe("first");
expect($g.get("second")).toBe("second");
});
it("handles obj.prop[idx] syntax (combined field name)", () => {
// In TorqueScript, obj.prop[idx] creates a field named "prop{idx}"
// e.g., Data.items[0] creates field "items0" on Data
const { $, $g } = run(`
new ScriptObject(Data) {};
Data.items[0] = "first";
Data.items[1] = "second";
$first = Data.items[0];
$second = Data.items[1];
`);
const data = $.deref("Data");
// Verify the fields are named items0, items1 (not nested)
expect($.prop(data, "items0")).toBe("first");
expect($.prop(data, "items1")).toBe("second");
expect($g.get("first")).toBe("first");
expect($g.get("second")).toBe("second");
});
it("handles post-increment on bareword object properties", () => {
const { $, $g } = run(`
new ScriptObject(Counter) {
value = 10;
};
$before = Counter.value++;
$after = Counter.value;
`);
expect($g.get("before")).toBe(10);
expect($g.get("after")).toBe(11);
});
it("handles compound assignment on bareword object properties", () => {
const { $, $g } = run(`
new ScriptObject(Score) {
points = 100;
};
Score.points += 50;
$result = Score.points;
`);
expect($g.get("result")).toBe(150);
});
});
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 Infinity)", () => {
const { $g } = run(`$result = 10 / 0;`);
expect($g.get("result")).toBe(Infinity);
});
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 activated", () => {
const { $g } = run(`
function getMessage() {
return "original";
}
$before = getMessage();
package Override {
function getMessage() {
return "overridden";
}
};
$stillOriginal = getMessage();
activatePackage(Override);
$after = getMessage();
`);
expect($g.get("before")).toBe("original");
expect($g.get("stillOriginal")).toBe("original"); // not yet activated
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() @ ")";
}
};
activatePackage(Extended);
$result = getValue();
`);
expect($g.get("result")).toBe("extended(base)");
});
it("handles nested Parent:: calls with multiple packages", () => {
// This tests that Parent:: correctly calls the parent in the stack,
// not just stack[length-2] which would cause infinite recursion
const { $g } = run(`
function getValue() {
return "base";
}
package Pkg1 {
function getValue() {
return "pkg1(" @ Parent::getValue() @ ")";
}
};
package Pkg2 {
function getValue() {
return "pkg2(" @ Parent::getValue() @ ")";
}
};
activatePackage(Pkg1);
activatePackage(Pkg2);
$result = getValue();
`);
expect($g.get("result")).toBe("pkg2(pkg1(base))");
});
it("handles nested Parent:: calls with three packages", () => {
const { $g } = run(`
function getValue() {
return "base";
}
package Pkg1 {
function getValue() {
return "p1(" @ Parent::getValue() @ ")";
}
};
package Pkg2 {
function getValue() {
return "p2(" @ Parent::getValue() @ ")";
}
};
package Pkg3 {
function getValue() {
return "p3(" @ Parent::getValue() @ ")";
}
};
activatePackage(Pkg1);
activatePackage(Pkg2);
activatePackage(Pkg3);
$result = getValue();
`);
expect($g.get("result")).toBe("p3(p2(p1(base)))");
});
it("handles nested Parent:: calls for methods", () => {
const { $g } = run(`
function TestClass::getValue(%this) {
return "base";
}
package Pkg1 {
function TestClass::getValue(%this) {
return "pkg1(" @ Parent::getValue(%this) @ ")";
}
};
package Pkg2 {
function TestClass::getValue(%this) {
return "pkg2(" @ Parent::getValue(%this) @ ")";
}
};
activatePackage(Pkg1);
activatePackage(Pkg2);
%obj = new ScriptObject(TestObj) { class = "TestClass"; };
$result = %obj.getValue();
`);
expect($g.get("result")).toBe("pkg2(pkg1(base))");
});
it("supports deferred activation (activatePackage before package is defined)", () => {
// This matches Torque engine behavior where activatePackage can be
// called before the package block is executed (common in mission scripts)
const { $g } = run(`
function getMessage() {
return "original";
}
// Activate package BEFORE it's defined
activatePackage(DeferredPkg);
// At this point, package doesn't exist yet, but activation is remembered
$beforeDefine = getMessage();
// Now define the package - should auto-activate because we called
// activatePackage earlier
package DeferredPkg {
function getMessage() {
return "deferred override";
}
};
$afterDefine = getMessage();
`);
expect($g.get("beforeDefine")).toBe("original");
expect($g.get("afterDefine")).toBe("deferred override");
});
it("deferred activation is case-insensitive", () => {
const { $g } = run(`
function test() { return "base"; }
activatePackage(MYPACKAGE);
package myPackage {
function test() { return "override"; }
};
$result = test();
`);
expect($g.get("result")).toBe("override");
});
});
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("object path resolution (nameToID)", () => {
it("resolves simple object names", () => {
const { $, $g } = run(`
new SimGroup(MissionGroup) {};
$id = nameToID("MissionGroup");
`);
const obj = $.deref("MissionGroup");
expect($g.get("id")).toBe(obj._id);
});
it("returns -1 for non-existent objects", () => {
const { $g } = run(`
$id = nameToID("NonExistent");
`);
expect($g.get("id")).toBe(-1);
});
it("resolves path with children using /", () => {
const { $, $g } = run(`
new SimGroup(MissionGroup) {
new SimGroup(Teams) {
new SimGroup(team0) {};
new SimGroup(team1) {};
};
};
$team0Id = nameToID("MissionGroup/Teams/team0");
$team1Id = nameToID("MissionGroup/Teams/team1");
$teamsId = nameToID("MissionGroup/Teams");
`);
const team0 = $.deref("team0");
const team1 = $.deref("team1");
const teams = $.deref("Teams");
expect($g.get("team0Id")).toBe(team0._id);
expect($g.get("team1Id")).toBe(team1._id);
expect($g.get("teamsId")).toBe(teams._id);
});
it("returns -1 when path segment not found", () => {
const { $g } = run(`
new SimGroup(MissionGroup) {
new SimGroup(Teams) {};
};
$id = nameToID("MissionGroup/Teams/team0");
`);
expect($g.get("id")).toBe(-1);
});
it("resolves paths with leading slash", () => {
const { $, $g } = run(`
new SimGroup(MissionGroup) {
new SimGroup(Teams) {};
};
$id = nameToID("/MissionGroup/Teams");
`);
const teams = $.deref("Teams");
expect($g.get("id")).toBe(teams._id);
});
it("resolves numeric object IDs", () => {
const { $ } = run(`
new SimGroup(MissionGroup) {};
`);
const obj = $.deref("MissionGroup");
// Test path resolution with numeric ID
const found = $.deref(String(obj._id));
expect(found).toBe(obj);
});
it("is case-insensitive for path segments", () => {
const { $, $g } = run(`
new SimGroup(MissionGroup) {
new SimGroup(Teams) {
new SimGroup(team0) {};
};
};
$id = nameToID("missiongroup/TEAMS/Team0");
`);
const team0 = $.deref("team0");
expect($g.get("id")).toBe(team0._id);
});
});
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);
});
it("isActivePackage checks if package is active", () => {
const { $g } = run(`
$beforeDefine = isActivePackage(TestPkg);
package TestPkg {
function dummy() { return 1; }
};
$afterDefine = isActivePackage(TestPkg);
activatePackage(TestPkg);
$afterActivate = isActivePackage(TestPkg);
`);
expect($g.get("beforeDefine")).toBe(false);
expect($g.get("afterDefine")).toBe(false); // defined but not active
expect($g.get("afterActivate")).toBe(true);
});
it("getPackageList returns active packages", () => {
const { $g } = run(`
package Pkg1 { function f1() {} };
package Pkg2 { function f2() {} };
$empty = getPackageList();
activatePackage(Pkg1);
activatePackage(Pkg2);
$list = getPackageList();
`);
expect($g.get("empty")).toBe("");
expect($g.get("list")).toBe("Pkg1 Pkg2");
});
});
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 defines and $.activatePackage activates", () => {
const { $, $f } = createRuntime();
$.registerFunction("getMessage", () => "original");
expect($f.call("getMessage")).toBe("original");
$.package("TestPackage", () => {
$.registerFunction("getMessage", () => "overridden");
});
expect($f.call("getMessage")).toBe("original"); // not yet activated
$.activatePackage("TestPackage");
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("complex scripts", () => {
it("sets globals and registers methods", () => {
const { $g, state } = run(`
$Game::numRoles = 3;
$Game::role0 = "Goalie";
$Game::role1 = "Defense";
$Game::role2 = "Offense";
function MyGame::onStart(%game) {
return "started";
}
`);
expect($g.get("Game::numRoles")).toBe(3);
expect($g.get("Game::role0")).toBe("Goalie");
expect($g.get("Game::role1")).toBe("Defense");
expect($g.get("Game::role2")).toBe("Offense");
expect(state.methods.has("MyGame")).toBe(true);
});
it("creates nested object hierarchies like mission files", () => {
const { $, state } = run(`
new SimGroup(MissionGroup) {
new TerrainBlock(Terrain) {
size = 1024;
};
new Sky(Sky) {
cloudSpeed = 1.5;
};
new SimGroup(PlayerDropPoints) {
new SpawnSphere(Spawn1) { position = "0 0 100"; };
new SpawnSphere(Spawn2) { position = "100 0 100"; };
};
};
`);
const missionGroup = $.deref("MissionGroup");
expect(missionGroup).toBeDefined();
expect(missionGroup._className).toBe("SimGroup");
expect($.deref("Terrain")).toBeDefined();
expect($.deref("Sky")).toBeDefined();
expect($.deref("Spawn1")).toBeDefined();
expect($.deref("Spawn2")).toBeDefined();
expect(state.objectsByName.size).toBe(6);
});
it("supports namespace method calls", () => {
const { $g } = run(`
function BaseGame::getValue(%game) {
return 10;
}
function BaseGame::getDoubled(%game) {
return BaseGame::getValue(%game) * 2;
}
$base = BaseGame::getValue(0);
$doubled = BaseGame::getDoubled(0);
`);
expect($g.get("base")).toBe(10);
expect($g.get("doubled")).toBe(20);
});
it("generates parent calls in transpiled code", () => {
const { code } = transpile(`
function MyGame::onEnd(%game) {
Parent::onEnd(%game);
}
`);
expect(code).toContain("$.parent(");
});
});
describe("exec() with loadScript", () => {
function createLoader(
files: Record<string, string>,
): (path: string) => Promise<string | null> {
// Loader normalizes paths itself (slashes + lowercase)
return async (path: string) =>
files[path.replace(/\\/g, "/").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);
});
describe("path normalization for state tracking", () => {
it("treats different cases as the same file for loaded scripts", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async (path) => {
loadCount++;
if (path.toLowerCase() === "scripts/test.cs") {
return "$LoadCount = $LoadCount + 1;";
}
return null;
},
});
// Load with different cases - should only call loader once
await runtime.loadFromPath("scripts/test.cs");
await runtime.loadFromPath("Scripts/Test.cs");
await runtime.loadFromPath("SCRIPTS/TEST.CS");
expect(loadCount).toBe(1);
});
it("treats different slashes as the same file for loaded scripts", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async (path) => {
loadCount++;
if (path.replace(/\\/g, "/").toLowerCase() === "scripts/test.cs") {
return "$LoadCount = $LoadCount + 1;";
}
return null;
},
});
// Load with different slash styles - should only call loader once
await runtime.loadFromPath("scripts/test.cs");
await runtime.loadFromPath("scripts\\test.cs");
expect(loadCount).toBe(1);
});
it("treats different cases as the same file for failed scripts", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async () => {
loadCount++;
return null; // Script not found
},
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// Try to load with different cases - should only call loader once
const script = await runtime.loadFromSource(`
exec("scripts/missing.cs");
exec("Scripts/Missing.cs");
exec("SCRIPTS/MISSING.CS");
`);
script.execute();
warnSpy.mockRestore();
// Loader should only be called once since subsequent attempts
// see it's already in failedScripts
expect(loadCount).toBe(1);
});
it("treats different slashes as the same file for failed scripts", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async () => {
loadCount++;
return null; // Script not found
},
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// Try to load with different slash styles
const script = await runtime.loadFromSource(`
exec("scripts/missing.cs");
exec("scripts\\\\missing.cs");
`);
script.execute();
warnSpy.mockRestore();
expect(loadCount).toBe(1);
});
it("treats different cases as the same file for executed scripts", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/counter.cs": "$Counter = $Counter + 1;",
}),
});
runtime.$g.set("Counter", 0);
const script = await runtime.loadFromSource(`
exec("scripts/counter.cs");
exec("Scripts/Counter.cs");
exec("SCRIPTS/COUNTER.CS");
`);
script.execute();
// Script should only execute once despite different cases
expect(runtime.$g.get("Counter")).toBe(1);
});
it("treats different slashes as the same file for executed scripts", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/counter.cs": "$Counter = $Counter + 1;",
}),
});
runtime.$g.set("Counter", 0);
const script = await runtime.loadFromSource(`
exec("scripts/counter.cs");
exec("scripts\\\\counter.cs");
`);
script.execute();
// Script should only execute once despite different slashes
expect(runtime.$g.get("Counter")).toBe(1);
});
it("caches sequential loads with different cases", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async (path) => {
loadCount++;
if (path.toLowerCase() === "scripts/test.cs") {
return "$Loaded = true;";
}
return null;
},
});
// Load same file with different cases sequentially
await runtime.loadFromPath("scripts/test.cs");
await runtime.loadFromPath("Scripts/Test.cs");
await runtime.loadFromPath("SCRIPTS/TEST.CS");
// Should only load once since cached after first load
expect(loadCount).toBe(1);
});
it("caches sequential loads with different slashes", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async (path) => {
loadCount++;
if (path.replace(/\\/g, "/").toLowerCase() === "scripts/test.cs") {
return "$Loaded = true;";
}
return null;
},
});
// Load same file with different slashes sequentially
await runtime.loadFromPath("scripts/test.cs");
await runtime.loadFromPath("scripts\\test.cs");
// Should only load once since cached after first load
expect(loadCount).toBe(1);
});
it("handles combined case and slash differences", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async (path) => {
loadCount++;
if (path.replace(/\\/g, "/").toLowerCase() === "scripts/test.cs") {
return "$Counter = $Counter + 1;";
}
return null;
},
});
runtime.$g.set("Counter", 0);
const script = await runtime.loadFromSource(`
exec("scripts/test.cs");
exec("Scripts\\\\Test.cs");
exec("SCRIPTS/TEST.CS");
exec("scripts\\\\TEST.CS");
`);
script.execute();
// All variations should be treated as the same file
expect(loadCount).toBe(1);
expect(runtime.$g.get("Counter")).toBe(1);
});
});
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("returns false and logs error when exec called without extension", async () => {
// Engine requires file extension (matches tribes2-engine/TorqueSDK-1.2 behavior)
const runtime = createRuntime();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
const script = await runtime.loadFromSource(
'$Result = exec("scripts/noextension");',
);
script.execute();
expect(errorSpy).toHaveBeenCalledWith(
'exec: invalid script file name "scripts/noextension".',
);
errorSpy.mockRestore();
debugSpy.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
});
it("handles diamond dependencies (parallel deduplication)", async () => {
// A depends on B and C, both B and C depend on D
// D should only be loaded once
let dLoadCount = 0;
const sources: Record<string, string> = {
"scripts/a.cs": 'exec("scripts/b.cs"); exec("scripts/c.cs"); $A = 1;',
"scripts/b.cs": 'exec("scripts/d.cs"); $B = 1;',
"scripts/c.cs": 'exec("scripts/d.cs"); $C = 1;',
"scripts/d.cs": "$D = 1;",
};
const runtime = createRuntime({
loadScript: async (path) => {
if (path === "scripts/d.cs") dLoadCount++;
return sources[path] ?? null;
},
});
const script = await runtime.loadFromPath("scripts/a.cs");
script.execute();
expect(dLoadCount).toBe(1); // D loaded only once despite being dep of both B and C
expect(runtime.$g.get("A")).toBe(1);
expect(runtime.$g.get("B")).toBe(1);
expect(runtime.$g.get("C")).toBe(1);
expect(runtime.$g.get("D")).toBe(1);
});
it("handles longer cycles not involving main script (A → B → C → B)", async () => {
const sources: Record<string, string> = {
"scripts/a.cs": 'exec("scripts/b.cs"); $A = 1;',
"scripts/b.cs": 'exec("scripts/c.cs"); $B = 1;',
"scripts/c.cs": 'exec("scripts/b.cs"); $C = 1;', // Cycle back to B
};
const runtime = createRuntime({
loadScript: async (path) => sources[path] ?? null,
});
// Should not hang
const script = await runtime.loadFromPath("scripts/a.cs");
script.execute();
expect(runtime.$g.get("A")).toBe(1);
expect(runtime.$g.get("B")).toBe(1);
expect(runtime.$g.get("C")).toBe(1);
});
it("handles multiple scripts depending on each other in complex patterns", async () => {
// A → [B, C], B → [D, E], C → [E, F], D → G, E → G, F → G
// G should only be loaded once
let gLoadCount = 0;
const sources: Record<string, string> = {
"scripts/a.cs": 'exec("scripts/b.cs"); exec("scripts/c.cs"); $A = 1;',
"scripts/b.cs": 'exec("scripts/d.cs"); exec("scripts/e.cs"); $B = 1;',
"scripts/c.cs": 'exec("scripts/e.cs"); exec("scripts/f.cs"); $C = 1;',
"scripts/d.cs": 'exec("scripts/g.cs"); $D = 1;',
"scripts/e.cs": 'exec("scripts/g.cs"); $E = 1;',
"scripts/f.cs": 'exec("scripts/g.cs"); $F = 1;',
"scripts/g.cs": "$G = 1;",
};
const runtime = createRuntime({
loadScript: async (path) => {
if (path === "scripts/g.cs") gLoadCount++;
return sources[path] ?? null;
},
});
const script = await runtime.loadFromPath("scripts/a.cs");
script.execute();
expect(gLoadCount).toBe(1); // G loaded only once
expect(runtime.$g.get("A")).toBe(1);
expect(runtime.$g.get("G")).toBe(1);
});
it("continues loading other scripts when one fails", async () => {
const sources: Record<string, string> = {
"scripts/main.cs":
'exec("scripts/good.cs"); exec("scripts/bad.cs"); exec("scripts/also-good.cs"); $Main = 1;',
"scripts/good.cs": "$Good = 1;",
// bad.cs is missing
"scripts/also-good.cs": "$AlsoGood = 1;",
};
const runtime = createRuntime({
loadScript: async (path) => sources[path] ?? null,
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
warnSpy.mockRestore();
// Good scripts should still be loaded and executed
expect(runtime.$g.get("Main")).toBe(1);
expect(runtime.$g.get("Good")).toBe(1);
expect(runtime.$g.get("AlsoGood")).toBe(1);
});
it("handles parse errors gracefully without blocking other scripts", async () => {
const sources: Record<string, string> = {
"scripts/main.cs":
'exec("scripts/good.cs"); exec("scripts/bad-syntax.cs"); $Main = 1;',
"scripts/good.cs": "$Good = 1;",
"scripts/bad-syntax.cs": "this is not valid { syntax",
};
const runtime = createRuntime({
loadScript: async (path) => sources[path] ?? null,
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
warnSpy.mockRestore();
expect(runtime.$g.get("Main")).toBe(1);
expect(runtime.$g.get("Good")).toBe(1);
});
it("respects pre-aborted signal when loading dependencies", async () => {
const controller = new AbortController();
controller.abort(); // Abort before starting
let loaderCalled = false;
const runtime = createRuntime({
loadScript: async () => {
loaderCalled = true;
return "$X = 1;";
},
signal: controller.signal,
});
// Load a script that has dependencies - should throw when trying to load deps
await expect(
runtime.loadFromSource('exec("scripts/dep.cs"); $Main = 1;'),
).rejects.toThrow();
expect(loaderCalled).toBe(false); // Loader should never be called
});
it("loads scripts in parallel (not serially)", async () => {
const loadOrder: string[] = [];
const sources: Record<string, string> = {
"scripts/main.cs":
'exec("scripts/a.cs"); exec("scripts/b.cs"); exec("scripts/c.cs");',
"scripts/a.cs": "$A = 1;",
"scripts/b.cs": "$B = 1;",
"scripts/c.cs": "$C = 1;",
};
const runtime = createRuntime({
loadScript: async (path) => {
loadOrder.push(`start:${path}`);
// Small delay to allow interleaving
await new Promise((resolve) => setTimeout(resolve, 10));
loadOrder.push(`end:${path}`);
return sources[path] ?? null;
},
});
await runtime.loadFromPath("scripts/main.cs");
// If parallel: starts happen before all ends
// If serial: each end would happen before the next start
const aStart = loadOrder.indexOf("start:scripts/a.cs");
const bStart = loadOrder.indexOf("start:scripts/b.cs");
const cStart = loadOrder.indexOf("start:scripts/c.cs");
const aEnd = loadOrder.indexOf("end:scripts/a.cs");
// All starts should happen before any of them ends (parallel behavior)
expect(bStart).toBeLessThan(aEnd);
expect(cStart).toBeLessThan(aEnd);
});
});
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);
});
});
describe("ScriptObject superClass inheritance", () => {
it("finds methods on superClass when not defined on class", () => {
const { $g } = run(`
function DefaultGame::getMessage(%this) {
return "default message";
}
// Create a ScriptObject with class and superClass
%game = new ScriptObject() {
class = "CTFGame";
superClass = "DefaultGame";
};
// CTFGame doesn't define getMessage, so it should find DefaultGame::getMessage
$result = %game.getMessage();
`);
expect($g.get("result")).toBe("default message");
});
it("prefers method on class over superClass", () => {
const { $g } = run(`
function DefaultGame::getMessage(%this) {
return "default message";
}
function CTFGame::getMessage(%this) {
return "CTF message";
}
%game = new ScriptObject() {
class = "CTFGame";
superClass = "DefaultGame";
};
$result = %game.getMessage();
`);
expect($g.get("result")).toBe("CTF message");
});
it("class method can call superClass method via namespace", () => {
const { $g } = run(`
function DefaultGame::getValue(%this) {
return 10;
}
function CTFGame::getValue(%this) {
%base = DefaultGame::getValue(%this);
return %base + 5;
}
%game = new ScriptObject() {
class = "CTFGame";
superClass = "DefaultGame";
};
$result = %game.getValue();
`);
expect($g.get("result")).toBe(15);
});
it("works with ScriptGroup as well", () => {
const { $g } = run(`
function BaseGroup::getType(%this) {
return "base";
}
%group = new ScriptGroup() {
class = "MyGroup";
superClass = "BaseGroup";
};
$result = %group.getType();
`);
expect($g.get("result")).toBe("base");
});
it("handles missing superClass method gracefully", () => {
const { $g } = run(`
%game = new ScriptObject() {
class = "CTFGame";
superClass = "DefaultGame";
};
// Neither CTFGame nor DefaultGame define this method
$result = %game.undefinedMethod();
`);
expect($g.get("result")).toBe("");
});
it("superClass is case-insensitive", () => {
const { $g } = run(`
function defaultgame::getMessage(%this) {
return "found it";
}
%game = new ScriptObject() {
class = "CTFGame";
superClass = "DefaultGame"; // Different case
};
$result = %game.getMessage();
`);
expect($g.get("result")).toBe("found it");
});
it("registers namespace parent link for other objects", () => {
// When one object establishes a class->superClass link,
// other objects with the same class should benefit from it
const { $g } = run(`
function DefaultGame::getInfo(%this) {
return "info";
}
// First object establishes the CTFGame -> DefaultGame link
%game1 = new ScriptObject() {
class = "CTFGame";
superClass = "DefaultGame";
};
// Second object with same class (no explicit superClass)
// should still walk the namespace chain
%game2 = new ScriptObject() {
class = "CTFGame";
};
$result1 = %game1.getInfo();
$result2 = %game2.getInfo();
`);
expect($g.get("result1")).toBe("info");
expect($g.get("result2")).toBe("info");
});
it("Parent:: walks namespace chain when no package override exists", () => {
// This tests the real TorqueScript behavior where Parent:: in a method
// walks up the namespace inheritance chain (set via superClass),
// not just the package override stack.
// This is how TDMGame::missionLoadDone can call Parent::missionLoadDone
// to invoke DefaultGame::missionLoadDone.
const { $g } = run(`
function DefaultGame::missionLoadDone(%game) {
%game.defaultCalled = true;
return "default";
}
function TDMGame::missionLoadDone(%game) {
// This Parent:: call should resolve to DefaultGame::missionLoadDone
// because TDMGame's superClass is DefaultGame
%result = Parent::missionLoadDone(%game);
%game.tdmCalled = true;
return "tdm(" @ %result @ ")";
}
// Create the Game object with class/superClass to establish namespace chain
%game = new ScriptObject(Game) {
class = "TDMGame";
superClass = "DefaultGame";
};
$result = %game.missionLoadDone();
$defaultCalled = %game.defaultCalled;
$tdmCalled = %game.tdmCalled;
`);
expect($g.get("result")).toBe("tdm(default)");
expect($g.get("defaultCalled")).toBe(true);
expect($g.get("tdmCalled")).toBe(true);
});
it("Parent:: walks multiple levels of namespace inheritance", () => {
const { $g } = run(`
function BaseGame::getValue(%this) {
return "base";
}
function DefaultGame::getValue(%this) {
return "default(" @ Parent::getValue(%this) @ ")";
}
function CTFGame::getValue(%this) {
return "ctf(" @ Parent::getValue(%this) @ ")";
}
// Establish the chain: CTFGame -> DefaultGame -> BaseGame
%dummy1 = new ScriptObject() {
class = "DefaultGame";
superClass = "BaseGame";
};
%game = new ScriptObject() {
class = "CTFGame";
superClass = "DefaultGame";
};
$result = %game.getValue();
`);
expect($g.get("result")).toBe("ctf(default(base))");
});
it("Parent:: through namespace chain finds package-overridden method", () => {
// Tests the interaction between namespace inheritance and package overrides:
// - BaseGame::getValue exists
// - Package overrides BaseGame::getValue
// - DefaultGame::getValue calls Parent::getValue
// - Should get the overridden version of BaseGame::getValue
const { $g } = run(`
function BaseGame::getValue(%this) {
return "base";
}
package Override {
function BaseGame::getValue(%this) {
return "overridden(" @ Parent::getValue(%this) @ ")";
}
};
function DefaultGame::getValue(%this) {
return "default(" @ Parent::getValue(%this) @ ")";
}
// Establish the chain: DefaultGame -> BaseGame
%game = new ScriptObject() {
class = "DefaultGame";
superClass = "BaseGame";
};
// First call without package
$resultWithout = %game.getValue();
// Activate package and call again
activatePackage(Override);
$resultWith = %game.getValue();
`);
// Without override, should walk chain to base
expect($g.get("resultWithout")).toBe("default(base)");
// With override active, should get overridden BaseGame::getValue
expect($g.get("resultWith")).toBe("default(overridden(base))");
});
it("Parent:: inside package override uses package stack, not namespace chain", () => {
// When inside a package override, Parent:: should call the previous
// version in the package stack, not walk the namespace chain
const { $g } = run(`
function DefaultGame::getValue(%this) {
return "default";
}
package Pkg1 {
function DefaultGame::getValue(%this) {
return "pkg1(" @ Parent::getValue(%this) @ ")";
}
};
// CTFGame inherits from DefaultGame
function CTFGame::getValue(%this) {
return "ctf(" @ Parent::getValue(%this) @ ")";
}
%game = new ScriptObject() {
class = "CTFGame";
superClass = "DefaultGame";
};
// Without package, CTFGame -> DefaultGame
$resultBefore = %game.getValue();
// With package active, CTFGame -> DefaultGame -> Pkg1's override
activatePackage(Pkg1);
$resultAfter = %game.getValue();
`);
expect($g.get("resultBefore")).toBe("ctf(default)");
expect($g.get("resultAfter")).toBe("ctf(pkg1(default))");
});
});
describe("method hooks (onMethodCalled)", () => {
it("fires hook after method is called via object", () => {
const runtime = createRuntime();
const hookCalls: Array<{ thisObj: any; args: any[] }> = [];
// Register hook before method is defined
runtime.$.onMethodCalled(
"TestClass",
"doSomething",
(thisObj, ...args) => {
hookCalls.push({ thisObj, args });
},
);
// Define method and create object via transpiled code
const { code } = transpile(`
function TestClass::doSomething(%this, %a, %b) {
return %a + %b;
}
%obj = new ScriptObject() {
class = "TestClass";
};
$result = %obj.doSomething(10, 20);
`);
const $l = runtime.$.locals();
new Function("$", "$f", "$g", "$l", code)(
runtime.$,
runtime.$f,
runtime.$g,
$l,
);
expect(runtime.$g.get("result")).toBe(30);
expect(hookCalls.length).toBe(1);
expect(hookCalls[0].args).toEqual([10, 20]);
});
it("fires hook after namespace call (e.g., DefaultGame::method)", () => {
const runtime = createRuntime();
const hookCalls: Array<{ thisObj: any; args: any[] }> = [];
runtime.$.onMethodCalled(
"DefaultGame",
"missionLoadDone",
(thisObj, ...args) => {
hookCalls.push({ thisObj, args });
},
);
const { code } = transpile(`
function DefaultGame::missionLoadDone(%game) {
$defaultCalled = true;
}
function CTFGame::missionLoadDone(%game) {
// Call parent via namespace
DefaultGame::missionLoadDone(%game);
$ctfCalled = true;
}
%game = new ScriptObject(Game) {
class = "CTFGame";
superClass = "DefaultGame";
};
%game.missionLoadDone();
`);
const $l = runtime.$.locals();
new Function("$", "$f", "$g", "$l", code)(
runtime.$,
runtime.$f,
runtime.$g,
$l,
);
expect(runtime.$g.get("defaultCalled")).toBe(true);
expect(runtime.$g.get("ctfCalled")).toBe(true);
// Hook should fire when DefaultGame::missionLoadDone is called
expect(hookCalls.length).toBe(1);
});
it("multiple hooks on same method all fire", () => {
const runtime = createRuntime();
const calls: string[] = [];
runtime.$.onMethodCalled("TestClass", "test", () => {
calls.push("hook1");
});
runtime.$.onMethodCalled("TestClass", "test", () => {
calls.push("hook2");
});
const { code } = transpile(`
function TestClass::test(%this) {
return "done";
}
%obj = new ScriptObject() {
class = "TestClass";
};
%obj.test();
`);
const $l = runtime.$.locals();
new Function("$", "$f", "$g", "$l", code)(
runtime.$,
runtime.$f,
runtime.$g,
$l,
);
expect(calls).toEqual(["hook1", "hook2"]);
});
it("hook receives correct thisObj", () => {
const runtime = createRuntime();
let capturedObj: any = null;
runtime.$.onMethodCalled("TestClass", "identify", (thisObj) => {
capturedObj = thisObj;
});
const { code } = transpile(`
function TestClass::identify(%this) {
return %this.name;
}
%obj = new ScriptObject(MyObject) {
class = "TestClass";
name = "test-object";
};
$result = %obj.identify();
`);
const $l = runtime.$.locals();
new Function("$", "$f", "$g", "$l", code)(
runtime.$,
runtime.$f,
runtime.$g,
$l,
);
expect(runtime.$g.get("result")).toBe("test-object");
expect(capturedObj).not.toBeNull();
expect(capturedObj._name).toBe("MyObject");
expect(capturedObj.name).toBe("test-object");
});
it("hook is case-insensitive for class and method names", () => {
const runtime = createRuntime();
let hookFired = false;
// Register with different case
runtime.$.onMethodCalled("testclass", "DOACTION", () => {
hookFired = true;
});
const { code } = transpile(`
function TestClass::doAction(%this) {
return true;
}
%obj = new ScriptObject() {
class = "TESTCLASS";
};
%obj.DoAction();
`);
const $l = runtime.$.locals();
new Function("$", "$f", "$g", "$l", code)(
runtime.$,
runtime.$f,
runtime.$g,
$l,
);
expect(hookFired).toBe(true);
});
it("hook fires for superClass method when called on subclass", () => {
const runtime = createRuntime();
const hookCalls: string[] = [];
runtime.$.onMethodCalled("DefaultGame", "init", () => {
hookCalls.push("DefaultGame::init");
});
const { code } = transpile(`
function DefaultGame::init(%this) {
$initialized = true;
}
// CTFGame doesn't override init, so DefaultGame::init will be called
%game = new ScriptObject() {
class = "CTFGame";
superClass = "DefaultGame";
};
%game.init();
`);
const $l = runtime.$.locals();
new Function("$", "$f", "$g", "$l", code)(
runtime.$,
runtime.$f,
runtime.$g,
$l,
);
expect(runtime.$g.get("initialized")).toBe(true);
expect(hookCalls).toEqual(["DefaultGame::init"]);
});
it("hook fires when parent method is called via Parent::", () => {
// This is the key test: when TDMGame::missionLoadDone calls
// Parent::missionLoadDone (which resolves to DefaultGame::missionLoadDone),
// the hook registered for DefaultGame::missionLoadDone should fire.
const runtime = createRuntime();
const hookCalls: string[] = [];
runtime.$.onMethodCalled("DefaultGame", "missionLoadDone", (thisObj) => {
hookCalls.push(
`DefaultGame::missionLoadDone called with ${thisObj._name}`,
);
});
const { code } = transpile(`
function DefaultGame::missionLoadDone(%game) {
%game.defaultCalled = true;
}
function TDMGame::missionLoadDone(%game) {
// Call parent via Parent:: - should trigger DefaultGame hook
Parent::missionLoadDone(%game);
%game.tdmCalled = true;
}
%game = new ScriptObject(Game) {
class = "TDMGame";
superClass = "DefaultGame";
};
%game.missionLoadDone();
`);
const $l = runtime.$.locals();
new Function("$", "$f", "$g", "$l", code)(
runtime.$,
runtime.$f,
runtime.$g,
$l,
);
const game = runtime.getObjectByName("Game");
expect(game?.defaultcalled).toBe(true);
expect(game?.tdmcalled).toBe(true);
// Hook should have fired when Parent::missionLoadDone resolved to DefaultGame::missionLoadDone
expect(hookCalls).toEqual([
"DefaultGame::missionLoadDone called with Game",
]);
});
it("hook fires for package override parent called via Parent::", () => {
const runtime = createRuntime();
const hookCalls: string[] = [];
runtime.$.onMethodCalled("TestClass", "getValue", () => {
hookCalls.push("TestClass::getValue");
});
const { code } = transpile(`
function TestClass::getValue(%this) {
return "base";
}
package Override {
function TestClass::getValue(%this) {
// Call parent - should trigger hook for TestClass::getValue (base version)
return "override(" @ Parent::getValue(%this) @ ")";
}
};
%obj = new ScriptObject() {
class = "TestClass";
};
activatePackage(Override);
$result = %obj.getValue();
`);
const $l = runtime.$.locals();
new Function("$", "$f", "$g", "$l", code)(
runtime.$,
runtime.$f,
runtime.$g,
$l,
);
expect(runtime.$g.get("result")).toBe("override(base)");
// Hook should fire twice: once for override, once for base via Parent::
expect(hookCalls).toEqual(["TestClass::getValue", "TestClass::getValue"]);
});
});
describe("isFunction", () => {
it("returns true for user-defined functions", () => {
const { $ } = run(`
function myCustomFunction() {
return "test";
}
`);
expect($.isFunction("myCustomFunction")).toBe(true);
expect($.isFunction("MYCUSTOMFUNCTION")).toBe(true); // case-insensitive
});
it("returns true for builtin functions", () => {
const { $ } = run(``);
expect($.isFunction("echo")).toBe(true);
expect($.isFunction("Echo")).toBe(true); // case-insensitive
expect($.isFunction("strlen")).toBe(true);
expect($.isFunction("getWord")).toBe(true);
});
it("returns false for non-existent functions", () => {
const { $ } = run(``);
expect($.isFunction("nonExistentFunction")).toBe(false);
});
});
describe("getWord delimiter handling", () => {
it("does not collapse consecutive delimiters", () => {
const { $g } = run(`
// With two spaces between a and b, getWord(1) should return empty
$str = "a b";
$word0 = getWord($str, 0); // "a"
$word1 = getWord($str, 1); // "" (empty - between two spaces)
$word2 = getWord($str, 2); // "b"
`);
expect($g.get("word0")).toBe("a");
expect($g.get("word1")).toBe(""); // Engine behavior: empty word between consecutive delimiters
expect($g.get("word2")).toBe("b");
});
it("handles multiple consecutive delimiters", () => {
const { $g } = run(`
$str = "a b"; // Three spaces
$word0 = getWord($str, 0); // "a"
$word1 = getWord($str, 1); // ""
$word2 = getWord($str, 2); // ""
$word3 = getWord($str, 3); // "b"
`);
expect($g.get("word0")).toBe("a");
expect($g.get("word1")).toBe("");
expect($g.get("word2")).toBe("");
expect($g.get("word3")).toBe("b");
});
it("correctly counts words with consecutive delimiters", () => {
const { $g } = run(`
$count1 = getWordCount("a b"); // 2 words
$count2 = getWordCount("a b"); // 3 words (includes empty)
$count3 = getWordCount("a b"); // 4 words (includes two empty)
`);
expect($g.get("count1")).toBe(2);
expect($g.get("count2")).toBe(3);
expect($g.get("count3")).toBe(4);
});
});
describe("ignoreScripts option", () => {
function createLoader(
files: Record<string, string>,
): (path: string) => Promise<string | null> {
return async (path: string) =>
files[path.replace(/\\/g, "/").toLowerCase()] ?? null;
}
it("skips scripts matching glob patterns", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const runtime = createRuntime({
loadScript: createLoader({
"scripts/main.cs": 'exec("scripts/ai/brain.cs"); $Main = 1;',
"scripts/ai/brain.cs": "$AI = 1;",
}),
ignoreScripts: ["**/ai/**"],
});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
expect(runtime.$g.get("Main")).toBe(1);
expect(runtime.$g.get("AI")).toBe(""); // Not loaded
expect(warnSpy).toHaveBeenCalledWith(
"Ignoring script: scripts/ai/brain.cs",
);
warnSpy.mockRestore();
});
it("is case insensitive", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/main.cs": 'exec("Scripts/AI/Brain.cs"); $Main = 1;',
"scripts/ai/brain.cs": "$AI = 1;",
}),
ignoreScripts: ["**/ai/**"],
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
warnSpy.mockRestore();
expect(runtime.$g.get("Main")).toBe(1);
expect(runtime.$g.get("AI")).toBe(""); // Not loaded due to case-insensitive match
});
it("supports multiple patterns", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/main.cs":
'exec("scripts/ai/brain.cs"); exec("scripts/vehicles/tank.cs"); exec("scripts/utils.cs"); $Main = 1;',
"scripts/ai/brain.cs": "$AI = 1;",
"scripts/vehicles/tank.cs": "$Vehicle = 1;",
"scripts/utils.cs": "$Utils = 1;",
}),
ignoreScripts: ["**/ai/**", "**/vehicles/**"],
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
warnSpy.mockRestore();
expect(runtime.$g.get("Main")).toBe(1);
expect(runtime.$g.get("AI")).toBe(""); // Ignored
expect(runtime.$g.get("Vehicle")).toBe(""); // Ignored
expect(runtime.$g.get("Utils")).toBe(1); // Loaded (not in ignore list)
});
it("marks ignored scripts as failed (not retried)", async () => {
let loadCount = 0;
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const runtime = createRuntime({
loadScript: async (path) => {
loadCount++;
if (path.toLowerCase() === "scripts/ignored.cs") {
return "$Ignored = 1;";
}
if (path.toLowerCase() === "scripts/main.cs") {
return 'exec("scripts/ignored.cs"); exec("scripts/ignored.cs"); $Main = 1;';
}
return null;
},
ignoreScripts: ["**/ignored.cs"],
});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
// Should only warn once (second exec sees it's already in failedScripts)
expect(
warnSpy.mock.calls.filter(
(c) => c[0] === "Ignoring script: scripts/ignored.cs",
).length,
).toBe(1);
// Loader should only be called for main.cs, not for ignored.cs
expect(loadCount).toBe(1);
warnSpy.mockRestore();
});
it("matches exact filenames with glob", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/main.cs":
'exec("scripts/skip-me.cs"); exec("scripts/keep-me.cs"); $Main = 1;',
"scripts/skip-me.cs": "$Skip = 1;",
"scripts/keep-me.cs": "$Keep = 1;",
}),
ignoreScripts: ["**/skip-me.cs"],
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
warnSpy.mockRestore();
expect(runtime.$g.get("Main")).toBe(1);
expect(runtime.$g.get("Skip")).toBe(""); // Ignored
expect(runtime.$g.get("Keep")).toBe(1); // Loaded
});
it("does nothing when ignoreScripts is empty array", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/main.cs": 'exec("scripts/dep.cs"); $Main = 1;',
"scripts/dep.cs": "$Dep = 1;",
}),
ignoreScripts: [],
});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
expect(runtime.$g.get("Main")).toBe(1);
expect(runtime.$g.get("Dep")).toBe(1); // Loaded normally
});
it("does nothing when ignoreScripts is not provided", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/main.cs": 'exec("scripts/dep.cs"); $Main = 1;',
"scripts/dep.cs": "$Dep = 1;",
}),
// No ignoreScripts option
});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
expect(runtime.$g.get("Main")).toBe(1);
expect(runtime.$g.get("Dep")).toBe(1); // Loaded normally
});
it("exec() returns false for ignored scripts", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/main.cs": '$Result = exec("scripts/ignored.cs"); $Main = 1;',
"scripts/ignored.cs": "$Ignored = 1;",
}),
ignoreScripts: ["**/ignored.cs"],
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
warnSpy.mockRestore();
expect(runtime.$g.get("Main")).toBe(1);
// exec() should return false when the script is ignored (same as failed)
expect(runtime.$g.get("Result")).toBe(false);
});
});
describe("nsRef with execution context", () => {
it("tracks execution context for Parent:: calls via nsRef", () => {
const runtime = createRuntime();
const { code } = transpile(`
function TestClass::getValue(%this) {
return "base";
}
package Pkg1 {
function TestClass::getValue(%this) {
return "pkg1(" @ Parent::getValue(%this) @ ")";
}
};
package Pkg2 {
function TestClass::getValue(%this) {
return "pkg2(" @ Parent::getValue(%this) @ ")";
}
};
activatePackage(Pkg1);
activatePackage(Pkg2);
`);
const $l = runtime.$.locals();
new Function("$", "$f", "$g", "$l", code)(
runtime.$,
runtime.$f,
runtime.$g,
$l,
);
// Get method reference via nsRef and call it directly
const fn = runtime.$.nsRef("TestClass", "getValue");
expect(fn).not.toBeNull();
// Call the method - it should track execution context for proper Parent:: support
const result = fn!("dummyThis");
expect(result).toBe("pkg2(pkg1(base))");
});
});
});