Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ npmPreapprovedPackages:
# https://yarnpkg.com/features/catalogs
# Define here to avoid duplications
catalog:
'@sourceacademy/conductor': ^0.8.2
'@sourceacademy/conductor': ^0.8.3
js-slang: ^1.0.94
react: ^19.0.0
react-dom: ^19.0.0
Expand Down
1 change: 1 addition & 0 deletions conductor-modules.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"plotly",
"repeat",
"repl",
"robot_simulation",
"rune",
"scrabble",
"sound"
Expand Down
10 changes: 8 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -447,8 +447,14 @@ export default defineConfig(
rules: {
// Rule doesn't work properly on CI
'@sourceacademy/throw-runtime-error': process.env.CI ? 'off' : ['error', {
// Conductor's own protocol-level errors, unrelated to js-slang's RuntimeSourceError
ignoredNames: ['EvaluatorTypeError', 'EvaluatorRuntimeError', 'EvaluatorParameterTypeError']
// Conductor's own protocol-level errors, unrelated to js-slang's RuntimeSourceError.
// ProgramError (robot_simulation) is the same idea for a bundle-internal error that never
// crosses the evaluator boundary at all (caught within the same bundle - see
// World.step()'s catch block in src/bundles/robot_simulation) - it used to satisfy this
// rule by genuinely extending js-slang's RuntimeSourceError, which pulled a real
// `require('js-slang/dist/errors/base')` into that bundle's build output even though
// robot_simulation no longer runs under js-slang (see ProgramError's own doc comment).
ignoredNames: ['EvaluatorTypeError', 'EvaluatorRuntimeError', 'EvaluatorParameterTypeError', 'ProgramError']
}]
}
},
Expand Down
10 changes: 8 additions & 2 deletions lib/buildtools/src/build/modules/commons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,13 @@ function convertAst(parsed: es.Program): ConvertAstResult {
* Write the compiled output from ESBuild to the file system after performing AST transformation
*/
export async function outputBundleOrTab({ text }: OutputFile, input: InputAsset, outDir: string): Promise<BuildResult> {
const parsed = parse(text, { ecmaVersion: 6 }) as es.Program;
// ecmaVersion bumped from 6 -> 2020: this is a purely mechanical AST-surgery pass (lifting the
// esbuild IIFE into an ExportDefaultDeclaration in convertAst below), not a target/syntax-lowering
// control — that's esbuild's own separate `target: 'es6'` option. ES6/acorn's parser can't parse
// BigInt literals (`0n`, added ES2020), which now show up here because py-slang (linked in for
// Python-flavoured bundles like robot_simulation) represents Python's arbitrary-precision ints as
// native BigInt at runtime and isn't in the esbuild `external` list the way 'js-slang*' is.
const parsed = parse(text, { ecmaVersion: 2020 }) as es.Program;

const astResult = convertAst(parsed);
if (astResult.severity === 'error') {
Expand Down Expand Up @@ -181,7 +187,7 @@ export function builderPlugin(input: InputAsset, outDir: string): ESBuildPlugin

onEnd(result => {
const [{ text }] = result.outputFiles!;
const parsed = parse(text, { ecmaVersion: 6 }) as es.Program;
const parsed = parse(text, { ecmaVersion: 2020 }) as es.Program;
const astResult = convertAst(parsed);
if (astResult.severity === 'success') {
generate(astResult.output, { output: writeStream });
Expand Down
25 changes: 25 additions & 0 deletions src/bundles/repl/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export default class ReplModulePlugin extends BaseModulePlugin {
private __evaluator: TypedValue<DataType.CLOSURE> | undefined;
private __tabLoaded = false;
private __tabRequested = false;
// Set by set_evaluator() when the Repl tab hasn't connected yet (loadTab() just kicked off an
// async, cross-thread tab bootstrap - see the 'request' handler below) - the focus send that
// would otherwise race that bootstrap is deferred until the tab's own 'request' arrives,
// guaranteeing it's actually subscribed by then instead of silently dropping the message.
private __focusOnConnect = false;
// Guards against two overlapping __runCode calls (e.g. a fast double-click on Run) driving the
// same evaluator closure concurrently - most evaluators (a tree-walking/CSE-machine interpreter)
// assume single-threaded, sequential calls and aren't safe to re-enter.
Expand Down Expand Up @@ -115,6 +120,14 @@ export default class ReplModulePlugin extends BaseModulePlugin {
this.__outputHistory.forEach(entry => this.__replChannel.send(entry));
if (this.__latestEditorProps) this.__replChannel.send(this.__latestEditorProps);
if (this.__latestProgramText) this.__replChannel.send(this.__latestProgramText);
// A 'request' only ever arrives once the tab's own channel subscription is already live
// (it's sent right after subscribing - see the Repl tab's constructor), so replying with
// 'focus' here can never race the bootstrap the way sending it directly from
// set_evaluator() could.
if (this.__focusOnConnect) {
this.__focusOnConnect = false;
this.__replChannel.send({ type: 'focus' });
}
return;
}

Expand Down Expand Up @@ -144,6 +157,18 @@ export default class ReplModulePlugin extends BaseModulePlugin {
// this, a program whose only interaction with the module is set_evaluator() never opens the
// tab at all, since nothing else would ever call __loadReplTab() first.
this.__loadReplTab();
// Explicit, not just a side effect of the tab's own constructor already calling showTab once:
// a program that calls set_evaluator() again later (or whose module import order put another
// tab-opening call after this one) still ends up back on the Repl tab, since that's what
// set_evaluator succeeding means for the student - "go use the Repl now". If the tab has
// already connected (__tabRequested), send it directly; otherwise loadTab() just kicked off an
// async bootstrap the tab hasn't caught up with yet, so defer to the 'request' handler above,
// which can send it without racing that bootstrap.
if (this.__tabRequested) {
this.__replChannel.send({ type: 'focus' });
} else {
this.__focusOnConnect = true;
}
return mVoid();
}

Expand Down
10 changes: 9 additions & 1 deletion src/bundles/repl/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@ export type ReplSetProgramTextMessage = {
text: string;
};

export type ReplDisplayMessage = ReplOutputMessage | ReplEditorPropsMessage | ReplSetProgramTextMessage;
/** Bundle -> tab: bring this tab to the front - sent once `set_evaluator` registers successfully,
* so a program that just finished wiring up the Repl (per the module's documented usage: setup in
* the main pane, then `set_evaluator` as its last step) lands the student on the Repl tab next,
rather than leaving them on the editor they just ran. */
export type ReplFocusMessage = {
type: 'focus';
};

export type ReplDisplayMessage = ReplOutputMessage | ReplEditorPropsMessage | ReplSetProgramTextMessage | ReplFocusMessage;

/** Tab -> bundle: run this code through whatever evaluator was registered via set_evaluator. */
export type ReplRunMessage = {
Expand Down
3 changes: 2 additions & 1 deletion src/bundles/robot_simulation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
"version": "1.0.0",
"private": true,
"dependencies": {
"@sourceacademy/conductor": "catalog:",
"@sourceacademy/modules-lib": "workspace:^",
"@sourceacademy/py-slang": "portal:/home/vakshay/Projects/local-pyslang-build/py-slang",
"es-toolkit": "^1.44.0",
"js-slang": "catalog:",
"three": "^0.185.0"
},
"devDependencies": {
Expand Down
203 changes: 203 additions & 0 deletions src/bundles/robot_simulation/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import rapier from '@dimforge/rapier3d-compat';
import { DataType } from '@sourceacademy/conductor/types';
import { TestDataHandler, numberValue, runAsyncGenerator, stringValue } from '@sourceacademy/modules-testplugin';
import { describe, expect, test, vi } from 'vitest';
import RobotSimulationModulePlugin from '..';

function makeRigidBody() {
return {
setTranslation: vi.fn(),
setRotation: vi.fn(),
translation: vi.fn(() => ({ x: 0, y: 0, z: 0 })),
rotation: vi.fn(() => ({ x: 0, y: 0, z: 0, w: 1 })),
linvel: vi.fn(() => ({ x: 0, y: 0, z: 0 })),
angvel: vi.fn(() => ({ x: 0, y: 0, z: 0 })),
applyImpulseAtPoint: vi.fn(),
};
}

function makeCollider() {
return { setMass: vi.fn(), mass: vi.fn(() => 0) };
}

// Same rapier mock as engine/__tests__/Physics.test.ts - init_default_simulation constructs a
// real Physics/World underneath, and this bundle otherwise has no way to step rapier's actual
// WASM in a plain vitest environment.
vi.mock(import('@dimforge/rapier3d-compat'), () => {
const mocked: typeof rapier = {
init: vi.fn(),
World: class {
timestep = vi.fn();
createRigidBody = vi.fn(() => makeRigidBody());
createCollider = vi.fn(() => makeCollider());
castRayAndGetNormal = vi.fn();
step = vi.fn();
castRay = vi.fn();
},
Ray: vi.fn(),
RigidBodyDesc: {
fixed: vi.fn(() => ({})),
dynamic: vi.fn(() => ({})),
} as any,
ColliderDesc: {
cuboid: vi.fn(() => ({})),
} as any,
} as any;
return { default: mocked };
});

function makePlugin() {
const controlChannel = { send: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn(), close: vi.fn(), name: 'control' };
const stateChannel = { send: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn(), close: vi.fn(), name: 'state' };
const evaluator = new TestDataHandler();
const tabLoader = { tabs: ['RobotSimulation'], loadTab: vi.fn() };
const plugin = new RobotSimulationModulePlugin(
{} as any,
[controlChannel, stateChannel] as any,
evaluator,
tabLoader
);
return { plugin, evaluator, controlChannel, stateChannel, tabLoader };
}

describe(RobotSimulationModulePlugin, () => {
test('every exported name carries an attached signature', () => {
const { plugin } = makePlugin();
const missing = plugin.exportedNames.filter(name => {
const method: unknown = (plugin as any)[name];
return typeof method !== 'function'
|| (method as { signature?: unknown }).signature === undefined;
});

expect(missing).toStrictEqual([]);
});

describe('init_default_simulation', () => {
test('builds a default world, loads the tab, and starts the simulation from one call', async () => {
const { plugin, tabLoader } = makePlugin();

const result = await runAsyncGenerator((plugin as any).init_default_simulation());

expect(result).toStrictEqual({ type: DataType.VOID, value: undefined });
expect(tabLoader.loadTab).toHaveBeenCalledWith('RobotSimulation');
});

test('is idempotent - a second call is a no-op once a world already exists', async () => {
const { plugin, controlChannel } = makePlugin();

await runAsyncGenerator((plugin as any).init_default_simulation());
const callsAfterFirst = controlChannel.send.mock.calls.length;
await runAsyncGenerator((plugin as any).init_default_simulation());

// No new world was built (and hence no new worldStateChanged RPC was queued) the second time.
expect(controlChannel.send.mock.calls.length).toBe(callsAfterFirst);
});

test('declares no parameters', () => {
const { signature } = (RobotSimulationModulePlugin.prototype as any).init_default_simulation;
expect(signature.args).toStrictEqual([]);
expect(signature.returnType).toBe(DataType.VOID);
});
});

describe('add_wall / add_paper', () => {
test('add a controller to the already-running default world without needing physics/world handles', async () => {
const { plugin } = makePlugin();
await runAsyncGenerator((plugin as any).init_default_simulation());

const world = (plugin as any).__state.world;
const controllersBefore = world.controllers.controllers.length;

await runAsyncGenerator(
(plugin as any).add_wall(
numberValue(0), numberValue(3), numberValue(2), numberValue(0.2), numberValue(1)
)
);
await runAsyncGenerator(
(plugin as any).add_paper(
stringValue('red.png'), numberValue(1), numberValue(1), numberValue(0), numberValue(1), numberValue(0)
)
);
await runAsyncGenerator(
(plugin as any).add_color_patch(
stringValue('red'), numberValue(0), numberValue(1), numberValue(0.5), numberValue(0.5)
)
);

// All three controllers were added live (start() already fired) rather than only queued for
// a future worldStart that already happened.
expect(world.controllers.controllers.length).toBe(controllersBefore + 3);
});

test('add_color_patch registers a real physics collider with its color, unlike add_paper', async () => {
const { plugin } = makePlugin();
await runAsyncGenerator((plugin as any).init_default_simulation());

const registerColorSpy = vi.spyOn((plugin as any).__state.world.physics, 'registerColor');

await runAsyncGenerator(
(plugin as any).add_color_patch(
stringValue('#ff0000'), numberValue(0), numberValue(1), numberValue(0.5), numberValue(0.5)
)
);

expect(registerColorSpy).toHaveBeenCalledWith(expect.anything(), '#ff0000');
});
});

describe('run_robot_code', () => {
test('drives the shared robot Python context across repeated calls, sharing state between runs', async () => {
const { plugin } = makePlugin();
await runAsyncGenerator((plugin as any).init_default_simulation());

await runAsyncGenerator((plugin as any).run_robot_code(stringValue('x = 1')));
const firstProgram = (plugin as any).__state.replProgram;

await runAsyncGenerator((plugin as any).run_robot_code(stringValue('y = x + 1')));
const secondProgram = (plugin as any).__state.replProgram;

// A fresh Program per call...
expect(secondProgram).not.toBe(firstProgram);
// ...but the first one is stopped so it can't keep pumping the shared pyContext.
expect((firstProgram as any).isStopped).toBe(true);
// ...and both runs share the same pyContext, so `y = x + 1` could resolve `x` at all
// (analyzePython would have thrown a NameError otherwise - see evaluate.ts).
expect((plugin as any).__state.replPyContext).toBeDefined();
});

test('throws if the world has not been initialised yet', async () => {
const { plugin } = makePlugin();
await expect(
runAsyncGenerator((plugin as any).run_robot_code(stringValue('ev3_pause(1)')))
).rejects.toThrow();
});

test('ev3_pause() pauses the currently-running Program, not a stale one from an earlier run', async () => {
const { plugin } = makePlugin();
await runAsyncGenerator((plugin as any).init_default_simulation());

// First run: a Program that finishes immediately and is left behind, stopped, in
// world.controllers.controllers - exactly what a real student's first REPL/embedded-editor
// Run leaves behind once they move on to a second one.
await runAsyncGenerator((plugin as any).run_robot_code(stringValue('x = 1')));
const firstProgram = (plugin as any).__state.replProgram;

// Second run calls ev3_pause() itself - if ev3_pause found the *first* Program with a
// matching name (the bug this guards against), it would pause a dead, already-stopped
// Program that no longer affects anything, leaving this run's own isPaused false forever.
await runAsyncGenerator((plugin as any).run_robot_code(stringValue('ev3_pause(1000000)')));
const secondProgram = (plugin as any).__state.replProgram;

// Drive the second run's Python code far enough to actually execute the ev3_pause() call
// (mirrors Program.python.test.ts's own real-py-slang pump pattern).
for (let tick = 0; tick < 10; tick++) {
secondProgram.fixedUpdate();
// eslint-disable-next-line no-await-in-loop
await new Promise(resolve => setTimeout(resolve, 0));
}

expect(secondProgram.isPaused).toBe(true);
expect(firstProgram.isPaused).toBe(false);
});
});
});
Loading
Loading