Skip to content

Commit ebe8bfe

Browse files
committed
dotbot/controller_app: offer to scaffold a simulator world file
AI-assisted: Claude Opus 4.8
1 parent 8239d3f commit ebe8bfe

2 files changed

Lines changed: 111 additions & 0 deletions

File tree

‎dotbot/controller_app.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
import asyncio
1111
import os
12+
import shutil
1213
import sys
14+
from pathlib import Path
1315

1416
import click
1517
import serial
@@ -90,6 +92,46 @@ def _conn_to_settings(conn, swarm_id, sim_is_dotbot):
9092
return {"adapter": "dotbot-simulator" if sim_is_dotbot else "sailbot-simulator"}
9193

9294

95+
def _maybe_scaffold_sim_state(explicit_init_state):
96+
"""Offer to drop an editable example world in the current directory.
97+
98+
`explicit_init_state` is the path set via `--simulator-init-state` or
99+
the config file, or None when unspecified (the default world). Fires
100+
only when nothing was specified and no `simulator_init_state.toml` is
101+
here. An interactive run gets a [Y/n] prompt; declining — or a
102+
non-interactive run (CI, a pipe) — leaves the cwd untouched and the
103+
simulator falls back to the packaged world, so it always starts.
104+
Writing the file lets the operator edit the simulated swarm
105+
(positions, count, Mari vs default mode).
106+
"""
107+
if explicit_init_state is not None:
108+
return # a path was set via --simulator-init-state or config
109+
if Path(SIMULATOR_INIT_STATE_DEFAULT).is_file():
110+
return # a cwd file already exists; it'll be used as-is
111+
if not sys.stdin.isatty():
112+
return # non-interactive: silently use the packaged default
113+
114+
target = Path.cwd() / SIMULATOR_INIT_STATE_DEFAULT
115+
if not click.confirm(
116+
f"No {SIMULATOR_INIT_STATE_DEFAULT} in this directory. "
117+
"Create an editable example here?",
118+
default=True,
119+
):
120+
return
121+
122+
from dotbot.dotbot_simulator import packaged_init_state_path
123+
124+
try:
125+
shutil.copy(packaged_init_state_path(), target)
126+
except OSError as exc:
127+
click.echo(
128+
f"Could not write {target}: {exc}; using the built-in world.",
129+
err=True,
130+
)
131+
return
132+
click.echo(f"Created {target} — edit it to customize the simulated swarm.")
133+
134+
93135
@click.command()
94136
@click.option(
95137
"-n",
@@ -229,6 +271,15 @@ def main(
229271
# implementation detail — the CLI never exposes it.
230272
conn_settings = _conn_to_settings(conn, swarm_id, sim_is_dotbot)
231273

274+
# For a simulator connection with no init-state set (CLI default is
275+
# None, so fold in any config value), offer to scaffold an editable
276+
# world file in the cwd. resolve_init_state_path then picks up the
277+
# freshly-written file (or the packaged world if declined/non-tty).
278+
if conn_settings.get("adapter", "").endswith("simulator"):
279+
_maybe_scaffold_sim_state(
280+
simulator_init_state or file_data.get("simulator_init_state")
281+
)
282+
232283
cli_args = {
233284
"gw_address": gw_address,
234285
"controller_http_port": controller_http_port,

‎dotbot/tests/test_controller_app.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,63 @@ def test_main_warns_on_legacy_config_keys(controller, _, tmp_path):
121121
# conn=simulator wins; the stale adapter/mqtt_host are ignored.
122122
assert settings.adapter == "dotbot-simulator"
123123
assert settings.mqtt_host != "stale"
124+
125+
126+
def test_scaffold_sim_state_creates_example_when_accepted(tmp_path, monkeypatch):
127+
"""Interactive simulator run with nothing specified + `y` writes an
128+
editable `simulator_init_state.toml` in the cwd."""
129+
from dotbot import SIMULATOR_INIT_STATE_DEFAULT
130+
from dotbot.controller_app import _maybe_scaffold_sim_state
131+
132+
monkeypatch.chdir(tmp_path)
133+
with patch("sys.stdin") as stdin, patch("click.confirm", return_value=True):
134+
stdin.isatty.return_value = True
135+
_maybe_scaffold_sim_state(None) # the value main() passes by default
136+
created = tmp_path / SIMULATOR_INIT_STATE_DEFAULT
137+
assert created.is_file()
138+
assert "[[dotbots]]" in created.read_text()
139+
140+
141+
def test_scaffold_sim_state_declined_writes_nothing(tmp_path, monkeypatch):
142+
monkeypatch.chdir(tmp_path)
143+
from dotbot import SIMULATOR_INIT_STATE_DEFAULT
144+
from dotbot.controller_app import _maybe_scaffold_sim_state
145+
146+
with patch("sys.stdin") as stdin, patch("click.confirm", return_value=False):
147+
stdin.isatty.return_value = True
148+
_maybe_scaffold_sim_state(None)
149+
assert not (tmp_path / SIMULATOR_INIT_STATE_DEFAULT).exists()
150+
151+
152+
def test_scaffold_sim_state_noninteractive_never_prompts(tmp_path, monkeypatch):
153+
"""No TTY (CI, a pipe) → no prompt, no file; the packaged world is used."""
154+
monkeypatch.chdir(tmp_path)
155+
from dotbot import SIMULATOR_INIT_STATE_DEFAULT
156+
from dotbot.controller_app import _maybe_scaffold_sim_state
157+
158+
with patch("sys.stdin") as stdin, patch("click.confirm") as confirm:
159+
stdin.isatty.return_value = False
160+
_maybe_scaffold_sim_state(None)
161+
confirm.assert_not_called()
162+
assert not (tmp_path / SIMULATOR_INIT_STATE_DEFAULT).exists()
163+
164+
165+
def test_scaffold_sim_state_skips_when_explicit_path_given(tmp_path, monkeypatch):
166+
monkeypatch.chdir(tmp_path)
167+
from dotbot.controller_app import _maybe_scaffold_sim_state
168+
169+
with patch("sys.stdin") as stdin, patch("click.confirm") as confirm:
170+
stdin.isatty.return_value = True
171+
_maybe_scaffold_sim_state("my_world.toml") # explicit path → no prompt
172+
confirm.assert_not_called()
173+
174+
175+
@patch("dotbot_utils.serial_interface.serial.Serial.open")
176+
@patch("dotbot.controller.Controller.run")
177+
@patch("dotbot.controller_app._maybe_scaffold_sim_state")
178+
def test_main_simulator_offers_scaffold_with_none(scaffold, _run, _serial):
179+
"""Regression: `--conn simulator` with no flag/config must reach the
180+
scaffold with None (the option default), not a sentinel string."""
181+
runner = CliRunner()
182+
runner.invoke(main, ["--conn", "simulator"])
183+
scaffold.assert_called_once_with(None)

0 commit comments

Comments
 (0)