Skip to content
Merged
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
66 changes: 57 additions & 9 deletions src/bundles/robot_simulation/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,15 @@ describe(RobotSimulationModulePlugin, () => {
});
});

describe('run_robot_code', () => {
describe('running code from the tab ($runReplCode)', () => {
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')));
(plugin as any).__runReplCode('x = 1');
const firstProgram = (plugin as any).__state.replProgram;

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

// A fresh Program per call...
Expand All @@ -165,11 +165,9 @@ describe(RobotSimulationModulePlugin, () => {
expect((plugin as any).__state.replPyContext).toBeDefined();
});

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

test('ev3_pause() pauses the currently-running Program, not a stale one from an earlier run', async () => {
Expand All @@ -179,13 +177,13 @@ describe(RobotSimulationModulePlugin, () => {
// 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')));
(plugin as any).__runReplCode('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)')));
(plugin as any).__runReplCode('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
Expand All @@ -200,4 +198,54 @@ describe(RobotSimulationModulePlugin, () => {
expect(firstProgram.isPaused).toBe(false);
});
});

describe('createPythonCSE', () => {
test('sends the control program text to the tab so its editor shows what is running', async () => {
const { plugin, stateChannel } = makePlugin();
await runAsyncGenerator((plugin as any).createPythonCSE(stringValue('x = 1')));

expect(stateChannel.send).toHaveBeenCalledWith({ kind: 'editor-code', code: 'x = 1' });
});

test('replays the program text to a tab that connects afterwards', async () => {
const { plugin, stateChannel } = makePlugin();
await runAsyncGenerator((plugin as any).createPythonCSE(stringValue('x = 2')));
stateChannel.send.mockClear();

const onMessage = stateChannel.subscribe.mock.calls[0][0];
onMessage({ kind: 'request-replay' });

expect(stateChannel.send).toHaveBeenCalledWith({ kind: 'editor-code', code: 'x = 2' });
});

test('running code from the tab afterwards replaces the program it made', async () => {
const { plugin } = makePlugin();
await runAsyncGenerator((plugin as any).init_default_simulation());
await runAsyncGenerator((plugin as any).createPythonCSE(stringValue('x = 1')));
const program = (plugin as any).__setupProgram;
expect(program.isStopped).toBe(false);

(plugin as any).__runReplCode('y = 1');

expect(program.isStopped).toBe(true);
});

test('rejects a second call, since a robot runs one program at a time', async () => {
const { plugin, stateChannel } = makePlugin();
await runAsyncGenerator((plugin as any).createPythonCSE(stringValue('x = 1')));
stateChannel.send.mockClear();

await expect(runAsyncGenerator((plugin as any).createPythonCSE(stringValue('x = 2')))).rejects.toThrow();
expect(stateChannel.send).not.toHaveBeenCalled();
});

test('sends nothing before createPythonCSE is called', () => {
const { stateChannel } = makePlugin();
const onMessage = stateChannel.subscribe.mock.calls[0][0];
stateChannel.send.mockClear();
onMessage({ kind: 'request-replay' });

expect(stateChannel.send).not.toHaveBeenCalledWith(expect.objectContaining({ kind: 'editor-code' }));
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class Program implements Controller {
* stashed here and re-thrown from the *next* `fixedUpdate` call instead, so it still surfaces
to (and is convertible by) the same call site a synchronous evaluator would throw from. */
private pendingError: unknown = null;
/** Set by `stop()` when a newer REPL run replaces this one - see `run_robot_code` (index.ts).
/** Set by `stop()` when a newer REPL run replaces this one - see `__runReplCode` (index.ts).
* Prevents this Program from pumping its (now-superseded) generator any further; several
* `Program`s can share one `pyContext` across REPL re-runs (that's how variables persist between
* runs), and `runPythonECEvaluator` reassigns `context.control`/`context.stash` at the *start* of
Expand Down
4 changes: 2 additions & 2 deletions src/bundles/robot_simulation/src/engine/World.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ export class World extends TypedEventTarget<WorldEventMap> {
* `addController`'s `start()` hookup only fires on the `worldStart` event, which for a live
* world already happened once inside `init()`, so a controller added afterwards would never get
* its `start()` called and would throw the first time it ticks (e.g. `Program.fixedUpdate`'s
* "Program not started"). Used by `run_robot_code` to add a fresh `Program` controller each time
* the REPL tab's registered evaluator runs, without restarting the whole world.
* "Program not started"). Used by `__runReplCode` (index.ts) to add a fresh `Program` controller each time
* the embedded editor's Run is pressed, without restarting the whole world.
*/
addLiveController(...controllers: Controller[]) {
this.addController(...controllers);
Expand Down
2 changes: 1 addition & 1 deletion src/bundles/robot_simulation/src/ev3_functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function createEv3Functions(deps: {
/**
* Pauses for a period of time.
*
* Every `run_robot_code`/`$runReplCode` call (each REPL/embedded-editor Run) adds a fresh
* Every `$runReplCode` call (each REPL/embedded-editor Run) adds a fresh
* `Program` controller rather than replacing one in place - see `Program`'s doc comment - so
* a World that has had more than one control-program run can have several `Program`s in
* `world.controllers.controllers` at once, all sharing the same `name`
Expand Down
93 changes: 41 additions & 52 deletions src/bundles/robot_simulation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@
*
* `from robot_simulation import ...` still only works for the *setup* program, not the robot's own
* control program: whether that code arrives as a string literal (`createPythonCSE`) or from the
* `repl` tab (`run_robot_code`), it's evaluated by a private, hand-built py-slang `Context` (see
* RobotSimulation tab's embedded editor, it's evaluated by a private, hand-built py-slang `Context` (see
* controllers/program/pythonRuntime.ts) stepped in lockstep with the physics tick, entirely
* separate from Conductor's own evaluator/module-loading machinery - there is no
* `ModuleLoaderRunnerPlugin` inside that shadow context for an `import` to resolve through. The
* `ev3_*` API is available to it directly by name instead (no import).
*
* The recommended student-facing shape is: `init_default_simulation()` + `add_wall`/`add_paper`
* calls in the main pane (one-time scene setup), then `set_evaluator(run_robot_code)` (from the
* `repl` module) to hand the robot's own code to a separate, rerunnable REPL tab - see
* `run_robot_code`'s doc comment.
* The robot's own code can be supplied two ways: `addControllerToWorld(createPythonCSE(code), world)`
* in a setup built with `init_simulation` (the code runs as soon as the simulation starts, and is
* shown in the tab's embedded editor), or typed into that embedded editor and run with its Run
* button - the way to go after `init_default_simulation()`, which takes no control program.
*
* @module robot_simulation
* @author Joel Chan
Expand Down Expand Up @@ -92,7 +92,6 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin {
'add_wall',
'add_paper',
'add_color_patch',
'run_robot_code',
'ev3_motorA',
'ev3_motorB',
'ev3_motorC',
Expand All @@ -113,6 +112,12 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin {
private readonly __sceneRegistry = new SceneRegistry();
private readonly __ev3Fns: Ev3Functions;
private __tabLoaded = false;
/** The latest control program text `createPythonCSE` was given, replayed to a tab that connects
after setup ran - see EditorCodeMessage. */
private __editorCode: string | undefined;
/** The one `Program` `createPythonCSE` may make - stopped by `__runReplCode` so that running code
from the tab's editor replaces it rather than driving the robot alongside it. */
private __setupProgram: Program | undefined;

/** What `saveToContext`/the `ev3_*` API read/write - one plugin instance per run, so this
replaces the pre-migration `context.moduleContexts.robot_simulation.state`. */
Expand All @@ -136,9 +141,9 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin {
this.__runReplCode(code);
} catch (error) {
// No live World yet (e.g. the embedded editor's Run button was clicked before the main
// program ran) - there is no evaluator/caller boundary here to surface this to, unlike
// run_robot_code's own throw (see its doc comment). The World-state readout already
// shown in this same tab makes "nothing is running yet" obvious without one.
// program ran) - there is no evaluator/caller boundary here to surface this to. The
// World-state readout already shown in this same tab makes "nothing is running yet"
// obvious without one.
console.warn('robot_simulation: could not run code from the RobotSimulation tab\'s embedded editor', error);
}
},
Expand All @@ -155,6 +160,9 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin {
this.__stateChannel.subscribe(message => {
if (message.kind === 'request-replay') {
this.__sceneRegistry.replaySpawns();
if (this.__editorCode !== undefined) {
this.__stateChannel.send({ kind: 'editor-code', code: this.__editorCode });
}
}
});
}
Expand Down Expand Up @@ -417,12 +425,25 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin {
* whole `ev3_*` API directly by name; no `import` is needed (and none is possible - see
* pythonRuntime.ts). `print(...)` goes to the simulation's Robot Console panel.
*
* A robot runs one program at a time, so this may only be called once per simulation - a second
* call throws rather than leaving two programs driving the same motors.
*
* Also shows `code` in the RobotSimulation tab's embedded editor (overwriting whatever it held),
* so the editor shows what is running. Running code from that editor afterwards replaces this
* program.
*
* @param code The robot's control program, written in Python (SICPy 搂4).
* @category Control Program
*/
async* createPythonCSE(code: TypedValue<DataType.CONST_STRING>): AsyncGenerator<void, TypedValue<DataType.OPAQUE>, undefined> {
if (this.__setupProgram !== undefined) {
throw new EvaluatorRuntimeError('createPythonCSE: a robot runs one program at a time, so it can only be called once per simulation');
}
const pyContext = createRobotPythonContext(this.__ev3Fns, () => this.__getWorldFromContext());
const program = new Program(code.value, undefined, pyContext);
this.__setupProgram = program;
Comment thread
martin-henz marked this conversation as resolved.
this.__editorCode = code.value;
this.__stateChannel.send({ kind: 'editor-code', code: code.value });
return await this.evaluator.opaque_make(program, true);
}

Expand Down Expand Up @@ -487,9 +508,9 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin {
/**
* The boilerplate-free alternative to `init_simulation`: builds default physics, a default
* world, a default floor and a default EV3 (the same defaults `createPhysics`/`createFloor`/
* `createEv3` use), and starts the simulation - all in one call. Takes no control program: pair
* this with `run_robot_code`/the `repl` module (see that method's doc comment) to drive the EV3
* from a separate, rerunnable REPL tab instead of a control-code string baked into setup.
* `createEv3` use), and starts the simulation - all in one call. Takes no control program: drive
* the EV3 by typing code into the RobotSimulation tab's embedded editor and pressing Run, instead
* of a control-code string baked into setup.
*
* For a customised World (non-default physics/gravity, a control program written in
* Source/Scheme instead of Python), use `createPhysics`/`createWorld`/`createWall`/
Expand Down Expand Up @@ -632,49 +653,19 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin {
}

/**
* The `repl`-module hook: pass this function itself to `repl`'s `set_evaluator`, and the `repl`
* tab it opens will call it with whatever the student typed there each time they hit Run -
* `code` is exactly what `createPythonCSE`/`init_default_simulation`'s old `control_code`
* argument used to be, just supplied interactively instead of baked into the setup program.
* Runs `code` as the robot's control program - the `$runReplCode` handler (the RobotSimulation
* tab's own embedded mini-editor - see protocol.ts's doc comment on `RobotSimulationModuleRpc`).
*
* Each call adds a fresh `Program` controller (via `World.addLiveController`, since the world is
* already running by this point) rather than editing one in place, but all calls share one
* `pyContext` (lazily created on the first call, cached in `__state`) - `runPythonECEvaluator`
* re-analyzes each run against that same context's global environment (see evaluate.ts), so
* variables and function defs a student's REPL code creates in one run are still visible in the
* next, the way a REPL is expected to behave. The previous run's `Program` is `stop()`'d first so
* it can't keep pumping its now-superseded generator against the same shared `pyContext` (see
* `Program.stop`'s doc comment).
* variables and function defs one run creates are still visible in the next, the way a REPL is
* expected to behave. The previous run's `Program`, and the one `createPythonCSE` made, are
* `stop()`'d first so they can't keep driving the robot (see `Program.stop`'s doc comment).
*
* Requires `init_default_simulation`/`init_simulation` to have already been called - there must
* be a live World for the robot code to act on.
*
* Also calls `$focusTab` on success, asking the RobotSimulation tab to bring itself to the
* front - but the frontend's side-content host only actually honours that the *first* time any
* tab is shown in a session (see `SideContentManager.showTab`'s "don't yank the student away from
* wherever they navigated" guard), so in practice this rarely does anything once the student has
* looked at any tab at all. A student who wants the 3D view and the code they're driving the
* robot with on screen *together*, without fighting that guard, should use the RobotSimulation
* tab's own embedded editor instead (`$runReplCode` in protocol.ts) - same effect as this
* function, just triggered from inside the tab that's already showing the 3D view, so there's
* nothing to focus/switch away from in the first place.
* @param code The robot's control program, written in Python (SICPy 搂4).
* @category Control Program
*/
async* run_robot_code(
code: TypedValue<DataType.CONST_STRING>
): AsyncGenerator<void, TypedValue<DataType.VOID>, undefined> {
this.__runReplCode(code.value);
return { type: DataType.VOID, value: undefined };
}

/**
* Shared by `run_robot_code` (the `repl`-module hook, above) and `$runReplCode` (the
* RobotSimulation tab's own embedded mini-editor - see protocol.ts's doc comment on
* `RobotSimulationModuleRpc`) - same effect either way, just reached from two different callers.
* Throws (via `__getWorldFromContext`) if no World exists yet; `run_robot_code` lets that
* propagate (repl displays it as an error), while the `$runReplCode` RPC handler catches and logs
* it instead, since there is no evaluator/caller boundary there to surface a throw to.
* Throws (via `__getWorldFromContext`) if no World exists yet; the `$runReplCode` RPC handler
* catches and logs it, since there is no evaluator/caller boundary there to surface a throw to.
*/
private __runReplCode(code: string): void {
const world = this.__getWorldFromContext();
Expand All @@ -685,12 +676,11 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin {
const pyContext = this.__state.replPyContext as ReturnType<typeof createRobotPythonContext>;

(this.__state.replProgram as Program | undefined)?.stop();
this.__setupProgram?.stop();

const program = new Program(code, undefined, pyContext);
world.addLiveController(program);
this.__state.replProgram = program;

this.__tabRpc.$focusTab();
}

// [EV3]
Expand Down Expand Up @@ -804,7 +794,6 @@ attachModuleMethod(RobotSimulationModulePlugin, 'init_default_simulation', [], D
attachModuleMethod(RobotSimulationModulePlugin, 'add_wall', [DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.VOID);
attachModuleMethod(RobotSimulationModulePlugin, 'add_paper', [DataType.CONST_STRING, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.VOID);
attachModuleMethod(RobotSimulationModulePlugin, 'add_color_patch', [DataType.CONST_STRING, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.VOID);
attachModuleMethod(RobotSimulationModulePlugin, 'run_robot_code', [DataType.CONST_STRING], DataType.VOID);
attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorA', [], DataType.OPAQUE);
attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorB', [], DataType.OPAQUE);
attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorC', [], DataType.OPAQUE);
Expand Down
Loading
Loading