From d59ef7acdce932f3998a5907261f8c6ce1ad0ce1 Mon Sep 17 00:00:00 2001 From: henz Date: Wed, 7 Oct 2026 14:38:15 +0800 Subject: [PATCH 1/4] feat(robot_simulation): show createPythonCSE's code in the tab's editor createPythonCSE now sends its program text to the RobotSimulation tab, which shows it in the embedded editor (overwriting any text restored from localStorage), so the editor shows what is running. The module replays the latest text to a tab that connects after setup. Running code from the embedded editor now replaces the createPythonCSE program instead of driving the robot alongside it. Closes #988 Co-Authored-By: Claude Sonnet 5.5 --- .../src/__tests__/index.test.ts | 42 +++++++++++++++++++ src/bundles/robot_simulation/src/index.ts | 17 ++++++++ src/bundles/robot_simulation/src/protocol.ts | 13 +++++- src/tabs/RobotSimulation/src/index.tsx | 34 ++++++++++++++- 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/bundles/robot_simulation/src/__tests__/index.test.ts b/src/bundles/robot_simulation/src/__tests__/index.test.ts index f8c30b04f3..042d8faa12 100644 --- a/src/bundles/robot_simulation/src/__tests__/index.test.ts +++ b/src/bundles/robot_simulation/src/__tests__/index.test.ts @@ -200,4 +200,46 @@ 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 latest program text to a tab that connects afterwards', async () => { + const { plugin, stateChannel } = makePlugin(); + await runAsyncGenerator((plugin as any).createPythonCSE(stringValue('x = 1'))); + 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); + + await runAsyncGenerator((plugin as any).run_robot_code(stringValue('y = 1'))); + + expect(program.isStopped).toBe(true); + }); + + 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' })); + }); + }); }); diff --git a/src/bundles/robot_simulation/src/index.ts b/src/bundles/robot_simulation/src/index.ts index cd21090365..67fa267f5c 100644 --- a/src/bundles/robot_simulation/src/index.ts +++ b/src/bundles/robot_simulation/src/index.ts @@ -113,6 +113,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 `Program` `createPythonCSE` made, if any - 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`. */ @@ -155,6 +161,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 }); + } } }); } @@ -417,12 +426,19 @@ 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. * + * 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): AsyncGenerator, undefined> { const pyContext = createRobotPythonContext(this.__ev3Fns, () => this.__getWorldFromContext()); const program = new Program(code.value, undefined, pyContext); + this.__setupProgram = program; + this.__editorCode = code.value; + this.__stateChannel.send({ kind: 'editor-code', code: code.value }); return await this.evaluator.opaque_make(program, true); } @@ -685,6 +701,7 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { const pyContext = this.__state.replPyContext as ReturnType; (this.__state.replProgram as Program | undefined)?.stop(); + this.__setupProgram?.stop(); const program = new Program(code, undefined, pyContext); world.addLiveController(program); diff --git a/src/bundles/robot_simulation/src/protocol.ts b/src/bundles/robot_simulation/src/protocol.ts index b2c3e5f927..c134e5c82a 100644 --- a/src/bundles/robot_simulation/src/protocol.ts +++ b/src/bundles/robot_simulation/src/protocol.ts @@ -55,13 +55,24 @@ export interface StateSnapshotMessage { buffer: ArrayBuffer; } +/** + * Module -> tab: the source text of the robot's control program, sent by `createPythonCSE` so the + * tab's embedded editor shows what is actually running. Overwrites whatever the editor held + * (including text it restored from `localStorage`). The module remembers the latest one and + * replays it on `request-replay`, like entity spawns, for a tab that connects after setup ran. + */ +export interface EditorCodeMessage { + kind: 'editor-code'; + code: string; +} + /** Tab -> module: replay every entity spawned so far (a tab that just mounted / reconnected), mirrors csg/rune's `{ type: 'request' }`. */ export interface RequestReplayMessage { kind: 'request-replay'; } -export type StateChannelMessage = EntitySpawnedMessage | StateSnapshotMessage | RequestReplayMessage; +export type StateChannelMessage = EntitySpawnedMessage | StateSnapshotMessage | EditorCodeMessage | RequestReplayMessage; export type WorldStateName = 'unintialized' | 'loading' | 'ready' | 'running' | 'error'; diff --git a/src/tabs/RobotSimulation/src/index.tsx b/src/tabs/RobotSimulation/src/index.tsx index 0bd942a3a6..d21dbc1f7c 100644 --- a/src/tabs/RobotSimulation/src/index.tsx +++ b/src/tabs/RobotSimulation/src/index.tsx @@ -28,6 +28,11 @@ interface ViewState { worldState: WorldStateName; sensors: SensorSnapshot | undefined; logs: readonly LogEntry[]; + /** + * Control-program text pushed by the module (`createPythonCSE`); `version` bumps on every push + * so the embedded editor can tell a repeat of identical text from no update. + */ + presetCode: { code: string, version: number } | undefined; } const MAX_LOGS = 200; @@ -131,6 +136,7 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio worldState: 'unintialized', sensors: undefined, logs: [], + presetCode: undefined, }; constructor(_conduit: IConduit, [controlChannel, stateChannel]: IChannel[], tabService: ITabService) { @@ -153,6 +159,8 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio this.__spawnEntity(message.id, message.descriptor); } else if (message.kind === 'state-snapshot') { this.__applySnapshot(message.buffer); + } else if (message.kind === 'editor-code') { + this.__presetEditorCode(message.code); } }); // A tab that mounts after the module has already spawned entities needs the backlog replayed @@ -205,6 +213,20 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio this.__emit(); } + /** + * The module's control program overwrites whatever the embedded editor held, including text + * it would restore from `localStorage` - so the cache is written here, not only by the editor, + * in case the editor has not mounted yet. + */ + private __presetEditorCode(code: string): void { + try { + window.localStorage.setItem(EMBEDDED_EDITOR_CODE_STORAGE_KEY, code); + } catch { + // Best-effort, like the editor's own saves. + } + this.__setState({ presetCode: { code, version: (this.__state.presetCode?.version ?? 0) + 1 } }); + } + private __spawnEntity(id: number, descriptor: EntityDescriptor): void { if (this.__entities.has(id)) return; @@ -522,7 +544,7 @@ function RobotSimulationView_({ ))} - + ); } @@ -538,7 +560,11 @@ function RobotSimulationView_({ * output history, background image, custom font size, `set_program_text` support, ...) - just * enough to type and run code, since output already has a home in this same tab's Robot Console. */ -function EmbeddedReplEditor({ onRunCode, height }: { onRunCode: (code: string) => void, height: number }) { +function EmbeddedReplEditor({ onRunCode, preset, height }: { + onRunCode: (code: string) => void; + preset: ViewState['presetCode']; + height: number; +}) { const [code, setCode] = useState(() => { try { return window.localStorage.getItem(EMBEDDED_EDITOR_CODE_STORAGE_KEY) ?? EMBEDDED_EDITOR_PLACEHOLDER; @@ -547,6 +573,10 @@ function EmbeddedReplEditor({ onRunCode, height }: { onRunCode: (code: string) = } }); + useEffect(() => { + if (preset !== undefined) setCode(preset.code); + }, [preset]); + const handleChange = (value: string) => { setCode(value); try { From 29611c495855c618153853249538a8b98978f4c1 Mon Sep 17 00:00:00 2001 From: henz Date: Wed, 7 Oct 2026 14:41:23 +0800 Subject: [PATCH 2/4] refactor(robot_simulation): remove run_robot_code and $focusTab With createPythonCSE running its code on init_simulation and showing it in the tab's editor, run_robot_code (the repl-module hook) is a second way to do the same thing. Code is now run either via createPythonCSE or from the tab's embedded editor. $focusTab was only ever sent from run_robot_code. Co-Authored-By: Claude Sonnet 5.5 --- .../src/__tests__/index.test.ts | 16 ++--- .../src/controllers/program/Program.ts | 2 +- .../robot_simulation/src/engine/World.ts | 4 +- .../robot_simulation/src/ev3_functions.ts | 2 +- src/bundles/robot_simulation/src/index.ts | 70 +++++-------------- src/bundles/robot_simulation/src/protocol.ts | 8 +-- src/tabs/RobotSimulation/src/index.tsx | 10 +-- 7 files changed, 33 insertions(+), 79 deletions(-) diff --git a/src/bundles/robot_simulation/src/__tests__/index.test.ts b/src/bundles/robot_simulation/src/__tests__/index.test.ts index 042d8faa12..789d0381db 100644 --- a/src/bundles/robot_simulation/src/__tests__/index.test.ts +++ b/src/bundles/robot_simulation/src/__tests__/index.test.ts @@ -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... @@ -167,9 +167,7 @@ describe(RobotSimulationModulePlugin, () => { 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(); + 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 () => { @@ -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 @@ -228,7 +226,7 @@ describe(RobotSimulationModulePlugin, () => { const program = (plugin as any).__setupProgram; expect(program.isStopped).toBe(false); - await runAsyncGenerator((plugin as any).run_robot_code(stringValue('y = 1'))); + (plugin as any).__runReplCode('y = 1'); expect(program.isStopped).toBe(true); }); diff --git a/src/bundles/robot_simulation/src/controllers/program/Program.ts b/src/bundles/robot_simulation/src/controllers/program/Program.ts index 1cc39e47eb..5520542a73 100644 --- a/src/bundles/robot_simulation/src/controllers/program/Program.ts +++ b/src/bundles/robot_simulation/src/controllers/program/Program.ts @@ -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 diff --git a/src/bundles/robot_simulation/src/engine/World.ts b/src/bundles/robot_simulation/src/engine/World.ts index 2f21ebe7ad..ceb43ff508 100644 --- a/src/bundles/robot_simulation/src/engine/World.ts +++ b/src/bundles/robot_simulation/src/engine/World.ts @@ -73,8 +73,8 @@ export class World extends TypedEventTarget { * `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); diff --git a/src/bundles/robot_simulation/src/ev3_functions.ts b/src/bundles/robot_simulation/src/ev3_functions.ts index 3005784134..4ca936ed7d 100644 --- a/src/bundles/robot_simulation/src/ev3_functions.ts +++ b/src/bundles/robot_simulation/src/ev3_functions.ts @@ -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` diff --git a/src/bundles/robot_simulation/src/index.ts b/src/bundles/robot_simulation/src/index.ts index 67fa267f5c..7b477ffc77 100644 --- a/src/bundles/robot_simulation/src/index.ts +++ b/src/bundles/robot_simulation/src/index.ts @@ -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 @@ -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', @@ -142,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); } }, @@ -503,9 +502,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`/ @@ -648,49 +647,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 - ): AsyncGenerator, 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(); @@ -706,8 +675,6 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { const program = new Program(code, undefined, pyContext); world.addLiveController(program); this.__state.replProgram = program; - - this.__tabRpc.$focusTab(); } // [EV3] @@ -821,7 +788,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); diff --git a/src/bundles/robot_simulation/src/protocol.ts b/src/bundles/robot_simulation/src/protocol.ts index c134e5c82a..10efb038b7 100644 --- a/src/bundles/robot_simulation/src/protocol.ts +++ b/src/bundles/robot_simulation/src/protocol.ts @@ -92,10 +92,6 @@ export interface RobotSimulationTabRpc { $consoleLog(message: string, level: 'error' | 'source'): void; $worldStateChanged(state: WorldStateName): void; $sensorSnapshot(snapshot: SensorSnapshot): void; - /** Brings the RobotSimulation tab to the front - sent once `run_robot_code` successfully hands a - * fresh run to the physics loop, so a student driving the robot from the `repl` tab lands back - on the 3D view to watch it, without having to switch tabs manually. */ - $focusTab(): void; } /** @@ -109,8 +105,8 @@ export interface RobotSimulationTabRpc { * render whatever a plugin wants, including its own split "3D view + code editor" layout in one. */ export interface RobotSimulationModuleRpc { - /** Same effect as `run_robot_code` (see index.ts) - runs `code` as the robot's control program - * against the shared REPL `pyContext`, replacing whatever the previous run left ticking. Silently + /** Runs `code` as the robot's control program against the shared REPL `pyContext`, replacing + * whatever the previous run (or the `createPythonCSE` program) left ticking. Silently * a no-op (logged, not thrown - there is no caller/evaluator boundary here to catch or display a * throw) if no World exists yet (the embedded editor's Run button was clicked before the main program set one up). */ diff --git a/src/tabs/RobotSimulation/src/index.tsx b/src/tabs/RobotSimulation/src/index.tsx index d21dbc1f7c..f4722e1641 100644 --- a/src/tabs/RobotSimulation/src/index.tsx +++ b/src/tabs/RobotSimulation/src/index.tsx @@ -465,16 +465,10 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio this.__setState({ sensors: snapshot }); } - $focusTab(): void { - this.__tabService.showTab(ROBOT_SIMULATION_TAB_ID); - } - /** * Called by the embedded mini-editor's Run button (see `RobotSimulationView_`) - sends the - * student's typed code to the module's `$runReplCode` (same effect as `run_robot_code`/the - * `repl` module's Run button, just from an editor that lives right next to the 3D view instead of - * a separate tab - see protocol.ts's doc comment on `RobotSimulationModuleRpc` for why this - * exists at all). Fire-and-forget: there's no return value to wait on, and any problem running + * student's typed code to the module's `$runReplCode` (see protocol.ts's doc comment on + * `RobotSimulationModuleRpc` for why the editor lives right next to the 3D view). Fire-and-forget: there's no return value to wait on, and any problem running * the code shows up in this same tab's own Robot Console (routed via `$consoleLog`) rather than coming back through this call. */ From 0d8299a29503f55df2fb452f1baf2c8bc436a837 Mon Sep 17 00:00:00 2001 From: henz Date: Wed, 7 Oct 2026 14:42:21 +0800 Subject: [PATCH 3/4] test(robot_simulation): drop stale async from a synchronous test Co-Authored-By: Claude Sonnet 5.5 --- src/bundles/robot_simulation/src/__tests__/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundles/robot_simulation/src/__tests__/index.test.ts b/src/bundles/robot_simulation/src/__tests__/index.test.ts index 789d0381db..6152251f4e 100644 --- a/src/bundles/robot_simulation/src/__tests__/index.test.ts +++ b/src/bundles/robot_simulation/src/__tests__/index.test.ts @@ -165,7 +165,7 @@ 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(); expect(() => (plugin as any).__runReplCode('ev3_pause(1)')).toThrow(); }); From 2c67588f8df263f90a53c28c50f43ee9034183fd Mon Sep 17 00:00:00 2001 From: henz Date: Wed, 7 Oct 2026 15:41:39 +0800 Subject: [PATCH 4/4] fix(robot_simulation): allow only one createPythonCSE program A robot runs one program at a time. Tracking only the latest createPythonCSE Program meant an earlier one kept running next to editor code. A second call now throws instead. Co-Authored-By: Claude Sonnet 5.5 --- .../robot_simulation/src/__tests__/index.test.ts | 12 ++++++++++-- src/bundles/robot_simulation/src/index.ts | 8 +++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/bundles/robot_simulation/src/__tests__/index.test.ts b/src/bundles/robot_simulation/src/__tests__/index.test.ts index 6152251f4e..734bf42544 100644 --- a/src/bundles/robot_simulation/src/__tests__/index.test.ts +++ b/src/bundles/robot_simulation/src/__tests__/index.test.ts @@ -207,9 +207,8 @@ describe(RobotSimulationModulePlugin, () => { expect(stateChannel.send).toHaveBeenCalledWith({ kind: 'editor-code', code: 'x = 1' }); }); - test('replays the latest program text to a tab that connects afterwards', async () => { + test('replays the program text to a tab that connects afterwards', async () => { const { plugin, stateChannel } = makePlugin(); - await runAsyncGenerator((plugin as any).createPythonCSE(stringValue('x = 1'))); await runAsyncGenerator((plugin as any).createPythonCSE(stringValue('x = 2'))); stateChannel.send.mockClear(); @@ -231,6 +230,15 @@ describe(RobotSimulationModulePlugin, () => { 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]; diff --git a/src/bundles/robot_simulation/src/index.ts b/src/bundles/robot_simulation/src/index.ts index 7b477ffc77..239f3c4526 100644 --- a/src/bundles/robot_simulation/src/index.ts +++ b/src/bundles/robot_simulation/src/index.ts @@ -115,7 +115,7 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { /** The latest control program text `createPythonCSE` was given, replayed to a tab that connects after setup ran - see EditorCodeMessage. */ private __editorCode: string | undefined; - /** The `Program` `createPythonCSE` made, if any - stopped by `__runReplCode` so that running code + /** 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; @@ -425,6 +425,9 @@ 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. @@ -433,6 +436,9 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { * @category Control Program */ async* createPythonCSE(code: TypedValue): AsyncGenerator, 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;